├── LICENSE
├── README.md
├── composer.json
├── extended-cpts.php
├── functions.php
└── src
├── Args
├── PostType.php
└── Taxonomy.php
├── ExtendedRewriteTesting.php
├── PostType.php
├── PostTypeAdmin.php
├── PostTypeRewriteTesting.php
├── Taxonomy.php
├── TaxonomyAdmin.php
├── TaxonomyRewriteTesting.php
├── Walker
├── Checkboxes.php
├── Dropdown.php
└── Radios.php
└── dashicons-codepoints.json
/LICENSE:
--------------------------------------------------------------------------------
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 | {{description}}
294 | Copyright (C) {{year}} {{fullname}}
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 | {signature of Ty Coon}, 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.
340 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/johnbillion/extended-cpts/actions)
2 | [](https://packagist.org/packages/johnbillion/extended-cpts)
3 | [](https://github.com/johnbillion/extended-cpts/blob/trunk/LICENSE)
4 | [](https://wordpress.org/support/update-php/)
5 | [](https://github.com/johnbillion/extended-cpts/wiki)
6 |
7 | # Extended CPTs #
8 |
9 | Extended CPTs is a library which provides extended functionality to WordPress custom post types and taxonomies. This allows developers to quickly build post types and taxonomies without having to write the same code again and again.
10 |
11 | Extended CPTs works with both the block editor and the classic editor.
12 |
13 | [See the wiki for full documentation.](https://github.com/johnbillion/extended-cpts/wiki)
14 |
15 | Not your first time here? See [Recent Changes for Developers](https://github.com/johnbillion/extended-cpts/wiki/Recent-Changes-for-Developers) to see what features are new in recent versions of Extended CPTs.
16 |
17 | ## Improved Defaults for Post Types ##
18 |
19 | * Automatically generated labels and post updated messages (in English)
20 | * Public post type with admin UI and post thumbnails enabled
21 | * Hierarchical with `page` capability type
22 | * Optimal admin menu placement
23 |
24 | ## Improved Defaults for Taxonomies ##
25 |
26 | * Automatically generated labels and term updated messages (in English)
27 | * Hierarchical public taxonomy with admin UI enabled
28 |
29 | ## Extended Admin Features ##
30 |
31 | * Declarative creation of table columns on the post type listing screen:
32 | * Columns for post meta, taxonomy terms, featured images, post fields, [Posts 2 Posts](https://wordpress.org/plugins/posts-to-posts/) connections, and custom functions
33 | * Sortable columns for post meta, taxonomy terms, and post fields
34 | * User capability restrictions
35 | * Default sort column and sort order
36 | * Declarative creation of table columns on the taxonomy term listing screen:
37 | * Columns for term meta and custom functions
38 | * User capability restrictions
39 | * Filter controls on the post type listing screen to enable filtering posts by post meta, taxonomy terms, post author, and post dates
40 | * Override the 'Featured Image' and 'Enter title here' text
41 | * Several custom meta boxes available for taxonomies on the post editing screen:
42 | * Simplified list of checkboxes
43 | * Radio buttons
44 | * Dropdown menu
45 | * Custom function
46 | * Post types and taxonomies automatically added to the 'At a Glance' section on the dashboard
47 | * Post types optionally added to the 'Recently Published' section on the dashboard
48 |
49 | ## Extended Front-end Features for Post Types ##
50 |
51 | * Specify a custom permalink structure:
52 | * For example `reviews/%year%/%month%/%review%`
53 | * Supports all relevant rewrite tags including dates and custom taxonomies
54 | * Automatic integration with the [Rewrite Rule Testing](https://wordpress.org/plugins/rewrite-testing/) plugin
55 | * Specify public query vars which enable filtering by post meta and post dates
56 | * Specify public query vars which enable sorting by post meta, taxonomy terms, and post fields
57 | * Override default public or private query vars such as `posts_per_page`, `orderby`, `order`, and `nopaging`
58 | * Add the post type to the site's main RSS feed
59 |
60 | ## Minimum Requirements ##
61 |
62 | * **PHP:** 7.4
63 | - Tested up to PHP 8.4
64 | * **WordPress:** 6.0
65 | - Tested up to WP 6.8
66 |
67 | ## Installation ##
68 |
69 | Extended CPTs is a developer library, not a plugin, which means you need to include it as a dependency in your project. Install it using Composer:
70 |
71 | ```bash
72 | composer require johnbillion/extended-cpts
73 | ```
74 |
75 | Other means of installation or usage, particularly bundling within a plugin, is not officially supported and done at your own risk.
76 |
77 | ## Usage ##
78 |
79 | Need a simple post type with no frills? You can register a post type with a single parameter:
80 |
81 | ```php
82 | add_action( 'init', function() {
83 | register_extended_post_type( 'article' );
84 | } );
85 | ```
86 |
87 | And you can register a taxonomy with just two parameters:
88 |
89 | ```php
90 | add_action( 'init', function() {
91 | register_extended_taxonomy( 'location', 'article' );
92 | } );
93 | ```
94 |
95 | Try it. You'll have a hierarchical public post type with an admin UI, a hierarchical public taxonomy with an admin UI, and all the labels and updated messages for them will be automatically generated.
96 |
97 | Or for a bit more functionality:
98 |
99 | ```php
100 | add_action( 'init', function() {
101 | register_extended_post_type( 'story', [
102 |
103 | # Add the post type to the site's main RSS feed:
104 | 'show_in_feed' => true,
105 |
106 | # Show all posts on the post type archive:
107 | 'archive' => [
108 | 'nopaging' => true,
109 | ],
110 |
111 | # Add some custom columns to the admin screen:
112 | 'admin_cols' => [
113 | 'story_featured_image' => [
114 | 'title' => 'Illustration',
115 | 'featured_image' => 'thumbnail'
116 | ],
117 | 'story_published' => [
118 | 'title_icon' => 'dashicons-calendar-alt',
119 | 'meta_key' => 'published_date',
120 | 'date_format' => 'd/m/Y'
121 | ],
122 | 'story_genre' => [
123 | 'taxonomy' => 'genre'
124 | ],
125 | ],
126 |
127 | # Add some dropdown filters to the admin screen:
128 | 'admin_filters' => [
129 | 'story_genre' => [
130 | 'taxonomy' => 'genre'
131 | ],
132 | 'story_rating' => [
133 | 'meta_key' => 'star_rating',
134 | ],
135 | ],
136 |
137 | ], [
138 |
139 | # Override the base names used for labels:
140 | 'singular' => 'Story',
141 | 'plural' => 'Stories',
142 | 'slug' => 'stories',
143 |
144 | ] );
145 |
146 | register_extended_taxonomy( 'genre', 'story', [
147 |
148 | # Use radio buttons in the meta box for this taxonomy on the post editing screen:
149 | 'meta_box' => 'radio',
150 |
151 | # Add a custom column to the admin screen:
152 | 'admin_cols' => [
153 | 'updated' => [
154 | 'title_cb' => function() {
155 | return 'Last Updated';
156 | },
157 | 'meta_key' => 'updated_date',
158 | 'date_format' => 'd/m/Y'
159 | ],
160 | ],
161 |
162 | ] );
163 | } );
164 | ```
165 |
166 | Bam, we now have:
167 |
168 | * A 'Stories' post type, with correctly generated labels and post updated messages, three custom columns in the admin area (two of which are sortable), stories added to the main RSS feed, and all stories displayed on the post type archive.
169 | * A 'Genre' taxonomy attached to the 'Stories' post type, with correctly generated labels and term updated messages, and a custom column in the admin area.
170 |
171 | The `register_extended_post_type()` and `register_extended_taxonomy()` functions are ultimately wrappers for the `register_post_type()` and `register_taxonomy()` functions in WordPress core, so any of the parameters from those functions can be used.
172 |
173 | There's quite a bit more you can do. [See the wiki for full documentation.](https://github.com/johnbillion/extended-cpts/wiki)
174 |
175 | ## Sponsors
176 |
177 |
The time that I spend maintaining this library and others is in part sponsored by:
178 |
179 |
180 |
181 |
182 |
183 | Plus all my kind sponsors on GitHub:
184 |
185 |
186 |
187 | Click here to find out about supporting my open source tools and plugins .
188 |
189 | ## Contributing and Testing ##
190 |
191 | Please see [CONTRIBUTING.md](CONTRIBUTING.md) for information on contributing.
192 |
193 | ## License: GPLv2 or later ##
194 |
195 | This program is free software; you can redistribute it and/or modify
196 | it under the terms of the GNU General Public License as published by
197 | the Free Software Foundation; either version 2 of the License, or
198 | (at your option) any later version.
199 |
200 | This program is distributed in the hope that it will be useful,
201 | but WITHOUT ANY WARRANTY; without even the implied warranty of
202 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
203 | GNU General Public License for more details.
204 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "johnbillion/extended-cpts",
3 | "description": "A library which provides extended functionality to WordPress custom post types and taxonomies.",
4 | "license": "GPL-2.0-or-later",
5 | "authors": [
6 | {
7 | "name": "John Blackbourn",
8 | "homepage": "https://johnblackbourn.com/"
9 | }
10 | ],
11 | "homepage": "https://github.com/johnbillion/extended-cpts/",
12 | "require": {
13 | "php": ">= 7.4.0",
14 | "johnbillion/args": "^1.4.1 || ^2.0"
15 | },
16 | "require-dev": {
17 | "behat/gherkin": "< 4.13.0",
18 | "automattic/phpcs-neutron-standard": "1.7.0",
19 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7",
20 | "johnbillion/falsey-assertequals-detector": "*",
21 | "johnbillion/plugin-infrastructure": "dev-trunk",
22 | "johnbillion/wp-compat": "0.3.1",
23 | "lucatume/wp-browser": "3.2.3",
24 | "phpcompatibility/phpcompatibility-wp": "2.1.5",
25 | "phpstan/phpstan": "1.12.12",
26 | "phpstan/phpstan-phpunit": "1.4.1",
27 | "roots/wordpress-core-installer": "1.100.0",
28 | "roots/wordpress-full": "*",
29 | "szepeviktor/phpstan-wordpress": "1.3.5",
30 | "wp-coding-standards/wpcs": "2.3.0"
31 | },
32 | "autoload": {
33 | "psr-4": {
34 | "ExtCPTs\\": "src",
35 | "ExtCPTs\\Tests\\": "tests/integration"
36 | },
37 | "files": [
38 | "functions.php"
39 | ]
40 | },
41 | "config": {
42 | "allow-plugins": {
43 | "dealerdirect/phpcodesniffer-composer-installer": true,
44 | "roots/wordpress-core-installer": true
45 | },
46 | "preferred-install": "dist",
47 | "sort-packages": true
48 | },
49 | "extra": {
50 | "wordpress-install-dir": "vendor/wordpress/wordpress"
51 | },
52 | "scripts": {
53 | "test": [
54 | "@composer validate --strict --no-check-lock",
55 | "@test:phpstan",
56 | "@test:phpcs",
57 | "@test:start",
58 | "@test:integration",
59 | "@test:stop"
60 | ],
61 | "test:destroy": [
62 | "tests-destroy"
63 | ],
64 | "test:integration": [
65 | "integration-tests"
66 | ],
67 | "test:phpcs": [
68 | "phpcs -nps --colors --report-code --report-summary --report-width=80 --cache=tests/cache/phpcs.json --basepath='./' ."
69 | ],
70 | "test:phpstan": [
71 | "codecept build",
72 | "phpstan analyze -v --memory-limit=1024M"
73 | ],
74 | "test:start": [
75 | "tests-start"
76 | ],
77 | "test:stop": [
78 | "tests-stop"
79 | ]
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/extended-cpts.php:
--------------------------------------------------------------------------------
1 |
9 | * @link https://github.com/johnbillion/extended-cpts
10 | * @copyright 2012-2025 John Blackbourn
11 | * @license GPL v2 or later
12 | * @version 5.0.12
13 | *
14 | * This program is free software; you can redistribute it and/or modify
15 | * it under the terms of the GNU General Public License as published by
16 | * the Free Software Foundation; either version 2 of the License, or
17 | * (at your option) any later version.
18 | *
19 | * This program is distributed in the hope that it will be useful,
20 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
21 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 | * GNU General Public License for more details.
23 | */
24 |
25 | require_once __DIR__ . '/functions.php';
26 | require_once __DIR__ . '/src/PostType.php';
27 | require_once __DIR__ . '/src/PostTypeAdmin.php';
28 | require_once __DIR__ . '/src/Taxonomy.php';
29 | require_once __DIR__ . '/src/TaxonomyAdmin.php';
30 | require_once __DIR__ . '/src/Walker/Checkboxes.php';
31 | require_once __DIR__ . '/src/Walker/Dropdown.php';
32 | require_once __DIR__ . '/src/Walker/Radios.php';
33 |
--------------------------------------------------------------------------------
/functions.php:
--------------------------------------------------------------------------------
1 | init();
62 |
63 | if ( is_admin() ) {
64 | $admin = new PostTypeAdmin( $cpt, $cpt->args );
65 | $admin->init();
66 | }
67 |
68 | return $cpt;
69 | }
70 |
71 | /**
72 | * Registers a custom taxonomy.
73 | *
74 | * The `$args` parameter accepts all the standard arguments for `register_taxonomy()` in addition to several custom
75 | * arguments that provide extended functionality. Some of the default arguments differ from the defaults in
76 | * `register_taxonomy()`.
77 | *
78 | * @link https://github.com/johnbillion/extended-cpts/wiki/Registering-taxonomies
79 | * @see register_taxonomy() for default arguments.
80 | *
81 | * @param string $taxonomy The taxonomy name.
82 | * @param string|string[] $object_type Name(s) of the object type(s) for the taxonomy.
83 | * @param mixed[] $args {
84 | * Optional. The taxonomy arguments.
85 | *
86 | * @type string $meta_box The name of the custom meta box to use on the post editing screen for this
87 | * taxonomy. Three custom meta boxes are provided: 'radio' for a meta box with radio
88 | * inputs, 'simple' for a meta box with a simplified list of checkboxes, and
89 | * 'dropdown' for a meta box with a dropdown menu. You can also pass the name of a
90 | * callback function, eg my_super_meta_box(), or boolean false to remove the meta
91 | * box. Default null, meaning the standard meta box is used.
92 | * @type bool $checked_ontop Whether to always show checked terms at the top of the meta box. This allows you
93 | * to override WordPress' default behaviour if necessary. Default false if you're
94 | * using a custom meta box (see the $meta_box argument), default true otherwise.
95 | * @type bool $dashboard_glance Whether to show this taxonomy on the 'At a Glance' section of the admin dashboard.
96 | * Default false.
97 | * @type array $admin_cols Associative array of admin screen columns to show for this taxonomy. See the
98 | * `TaxonomyAdmin::cols()` method for more information.
99 | * @type bool $exclusive This parameter isn't feature complete. All it does currently is set the meta box
100 | * to the 'radio' meta box, thus meaning any given post can only have one term
101 | * associated with it for that taxonomy. 'exclusive' isn't really the right name for
102 | * this, as terms aren't exclusive to a post, but rather each post can exclusively
103 | * have only one term. It's not feature complete because you can edit a post in
104 | * Quick Edit and give it more than one term from the taxonomy.
105 | * @type bool $allow_hierarchy All this does currently is disable hierarchy in the taxonomy's rewrite rules.
106 | * Default false.
107 | * }
108 | * @param string[] $names {
109 | * Optional. The plural, singular, and slug names.
110 | *
111 | * @type string $plural The plural form of the taxonomy name.
112 | * @type string $singular The singular form of the taxonomy name.
113 | * @type string $slug The slug used in the term permalinks for this taxonomy.
114 | * }
115 | * @phpstan-param array{
116 | * plural?: string,
117 | * singular?: string,
118 | * slug?: string,
119 | * } $names
120 | * @return Taxonomy
121 | */
122 | function register_extended_taxonomy( string $taxonomy, $object_type, array $args = [], array $names = [] ): Taxonomy {
123 | if ( ! did_action( 'init' ) ) {
124 | trigger_error( esc_html__( 'Taxonomies must be registered on the "init" hook.', 'extended-cpts' ), E_USER_WARNING );
125 | }
126 |
127 | $taxo = new Taxonomy( $taxonomy, (array) $object_type, $args, $names );
128 | $taxo->init();
129 |
130 | if ( is_admin() ) {
131 | $admin = new TaxonomyAdmin( $taxo, $taxo->args );
132 | $admin->init();
133 | }
134 |
135 | return $taxo;
136 | }
137 |
--------------------------------------------------------------------------------
/src/Args/PostType.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | public array $admin_cols;
13 |
14 | /**
15 | * Associative array of admin screen filters to show for this post type.
16 | *
17 | * @var array
18 | */
19 | public array $admin_filters;
20 |
21 | /**
22 | * Associative array of query vars to override on this post type's archive.
23 | *
24 | * @var array
25 | */
26 | public array $archive;
27 |
28 | /**
29 | * Force the use of the block editor for this post type. Must be used in
30 | * combination with the `show_in_rest` argument.
31 | *
32 | * The primary use of this argument
33 | * is to prevent the block editor from being used by setting it to false when
34 | * `show_in_rest` is set to true.
35 | */
36 | public bool $block_editor;
37 |
38 | /**
39 | * Whether to show this post type on the 'At a Glance' section of the admin
40 | * dashboard.
41 | *
42 | * Default true.
43 | */
44 | public bool $dashboard_glance;
45 |
46 | /**
47 | * Whether to show this post type on the 'Recently Published' section of the
48 | * admin dashboard.
49 | *
50 | * Default true.
51 | */
52 | public bool $dashboard_activity;
53 |
54 | /**
55 | * Placeholder text which appears in the title field for this post type.
56 | */
57 | public string $enter_title_here;
58 |
59 | /**
60 | * Text which replaces the 'Featured Image' phrase for this post type.
61 | */
62 | public string $featured_image;
63 |
64 | /**
65 | * Whether to show Quick Edit links for this post type.
66 | *
67 | * Default true.
68 | */
69 | public bool $quick_edit;
70 |
71 | /**
72 | * Whether to include this post type in the site's main feed.
73 | *
74 | * Default false.
75 | */
76 | public bool $show_in_feed;
77 |
78 | /**
79 | * Associative array of query vars and their parameters for front end filtering.
80 | *
81 | * @var array
82 | */
83 | public array $site_filters;
84 |
85 | /**
86 | * Associative array of query vars and their parameters for front end sorting.
87 | *
88 | * @var array
89 | */
90 | public array $site_sortables;
91 | }
92 |
--------------------------------------------------------------------------------
/src/Args/Taxonomy.php:
--------------------------------------------------------------------------------
1 |
46 | */
47 | public array $admin_cols;
48 |
49 | /**
50 | * This parameter isn't feature complete. All it does currently is set the meta box
51 | * to the 'radio' meta box, thus meaning any given post can only have one term
52 | * associated with it for that taxonomy.
53 | *
54 | * 'exclusive' isn't really the right name for this, as terms aren't exclusive to a
55 | * post, but rather each post can exclusively have only one term. It's not feature
56 | * complete because you can edit a post in Quick Edit and give it more than one term
57 | * from the taxonomy.
58 | */
59 | public bool $exclusive;
60 |
61 | /**
62 | * All this does currently is disable hierarchy in the taxonomy's rewrite rules.
63 | *
64 | * Default false.
65 | */
66 | public bool $allow_hierarchy;
67 | }
68 |
--------------------------------------------------------------------------------
/src/ExtendedRewriteTesting.php:
--------------------------------------------------------------------------------
1 | >
13 | */
14 | abstract public function get_tests(): array;
15 |
16 | /**
17 | * @param array $struct
18 | * @param array $additional
19 | * @return array
20 | */
21 | public function get_rewrites( array $struct, array $additional ): array {
22 | global $wp_rewrite;
23 |
24 | if ( ! $wp_rewrite->using_permalinks() ) {
25 | return [];
26 | }
27 |
28 | $new = [];
29 | $rules = $wp_rewrite->generate_rewrite_rules(
30 | $struct['struct'],
31 | $struct['ep_mask'],
32 | $struct['paged'],
33 | $struct['feed'],
34 | $struct['forcomments'],
35 | $struct['walk_dirs'],
36 | $struct['endpoints']
37 | );
38 | $rules = array_merge( $rules, $additional );
39 | $feedregex = implode( '|', $wp_rewrite->feeds );
40 | $replace = [
41 | '(.+?)' => 'hello',
42 | '.+?' => 'hello',
43 | '([^/]+)' => 'world',
44 | '[^/]+' => 'world',
45 | '(?:/([0-9]+))?' => '/456',
46 | '([0-9]{4})' => date( 'Y' ),
47 | '[0-9]{4}' => date( 'Y' ),
48 | '([0-9]{1,2})' => date( 'm' ),
49 | '[0-9]{1,2}' => date( 'm' ),
50 | '([0-9]{1,})' => '123',
51 | '[0-9]{1,}' => '789',
52 | '([0-9]+)' => date( 'd' ),
53 | '[0-9]+' => date( 'd' ),
54 | "({$feedregex})" => end( $wp_rewrite->feeds ),
55 | '/?' => '/',
56 | '$' => '',
57 | ];
58 |
59 | foreach ( $rules as $regex => $result ) {
60 | $regex = str_replace( array_keys( $replace ), $replace, $regex );
61 | // Change '$2' to '$matches[2]'
62 | $result = preg_replace( '/\$([0-9]+)/', '\$matches[$1]', $result );
63 | $new[ "/{$regex}" ] = $result;
64 | if ( false !== strpos( $regex, $replace['(?:/([0-9]+))?'] ) ) {
65 | // Add an extra rule for this optional block
66 | $regex = str_replace( $replace['(?:/([0-9]+))?'], '', $regex );
67 | $new[ "/{$regex}" ] = $result;
68 | }
69 | }
70 |
71 | return $new;
72 | }
73 |
74 | }
75 |
--------------------------------------------------------------------------------
/src/PostType.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | protected array $defaults = [
23 | 'public' => true,
24 | 'menu_position' => 6,
25 | 'capability_type' => 'page',
26 | 'hierarchical' => true,
27 | 'supports' => [
28 | 'title',
29 | 'editor',
30 | 'thumbnail',
31 | ],
32 | 'site_filters' => null, # Custom arg
33 | 'site_sortables' => null, # Custom arg
34 | 'show_in_feed' => false, # Custom arg
35 | 'archive' => null, # Custom arg
36 | 'featured_image' => null, # Custom arg
37 | ];
38 |
39 | public string $post_type;
40 |
41 | public string $post_slug;
42 |
43 | public string $post_singular;
44 |
45 | public string $post_plural;
46 |
47 | public string $post_singular_low;
48 |
49 | public string $post_plural_low;
50 |
51 | /**
52 | * @var array
53 | */
54 | public array $args;
55 |
56 | /**
57 | * Class constructor.
58 | *
59 | * @see register_extended_post_type()
60 | *
61 | * @param string $post_type The post type name.
62 | * @param array $args Optional. The post type arguments.
63 | * @param array $names Optional. The plural, singular, and slug names.
64 | * @phpstan-param array{
65 | * plural?: string,
66 | * singular?: string,
67 | * slug?: string,
68 | * } $names
69 | */
70 | public function __construct( string $post_type, array $args = [], array $names = [] ) {
71 | /**
72 | * Filter the arguments for a post type.
73 | *
74 | * @since 4.4.1
75 | *
76 | * @param array $args The post type arguments.
77 | * @param string $post_type The post type name.
78 | */
79 | $args = apply_filters( 'ext-cpts/args', $args, $post_type );
80 |
81 | /**
82 | * Filter the arguments for this post type.
83 | *
84 | * @since 2.4.0
85 | *
86 | * @param array $args The post type arguments.
87 | */
88 | $args = apply_filters( "ext-cpts/{$post_type}/args", $args );
89 |
90 | /**
91 | * Filter the plural, singular, and slug names for a post type.
92 | *
93 | * @since 4.4.1
94 | *
95 | * @param array $names The plural, singular, and slug names (if any were specified).
96 | * @param string $post_type The post type name.
97 | */
98 | $names = apply_filters( 'ext-cpts/names', $names, $post_type );
99 |
100 | /**
101 | * Filter the plural, singular, and slug names for this post type.
102 | *
103 | * @since 2.4.0
104 | *
105 | * @param array $names The plural, singular, and slug names (if any were specified).
106 | */
107 | $names = apply_filters( "ext-cpts/{$post_type}/names", $names );
108 |
109 | if ( isset( $names['singular'] ) ) {
110 | $this->post_singular = $names['singular'];
111 | } else {
112 | $this->post_singular = ucwords(
113 | str_replace(
114 | [
115 | '-',
116 | '_',
117 | ],
118 | ' ',
119 | $post_type
120 | )
121 | );
122 | }
123 |
124 | if ( isset( $names['slug'] ) ) {
125 | $this->post_slug = $names['slug'];
126 | } elseif ( isset( $names['plural'] ) ) {
127 | $this->post_slug = $names['plural'];
128 | } else {
129 | $this->post_slug = $post_type . 's';
130 | }
131 |
132 | if ( isset( $names['plural'] ) ) {
133 | $this->post_plural = $names['plural'];
134 | } else {
135 | $this->post_plural = $this->post_singular . 's';
136 | }
137 |
138 | $this->post_type = strtolower( $post_type );
139 | $this->post_slug = strtolower( $this->post_slug );
140 |
141 | # Build our base post type names:
142 | # Lower-casing is not forced if the name looks like an initialism, eg. FAQ.
143 | if ( ! preg_match( '/[A-Z]{2,}/', $this->post_singular ) ) {
144 | $this->post_singular_low = strtolower( $this->post_singular );
145 | } else {
146 | $this->post_singular_low = $this->post_singular;
147 | }
148 |
149 | if ( ! preg_match( '/[A-Z]{2,}/', $this->post_plural ) ) {
150 | $this->post_plural_low = strtolower( $this->post_plural );
151 | } else {
152 | $this->post_plural_low = $this->post_plural;
153 | }
154 |
155 | # Build our labels:
156 | # Why aren't these translatable?
157 | # Answer: https://github.com/johnbillion/extended-cpts/pull/5#issuecomment-33756474
158 | $this->defaults['labels'] = [
159 | 'name' => $this->post_plural,
160 | 'singular_name' => $this->post_singular,
161 | 'menu_name' => $this->post_plural,
162 | 'name_admin_bar' => $this->post_singular,
163 | 'add_new' => sprintf( 'Add New %s', $this->post_singular ),
164 | 'add_new_item' => sprintf( 'Add New %s', $this->post_singular ),
165 | 'edit_item' => sprintf( 'Edit %s', $this->post_singular ),
166 | 'new_item' => sprintf( 'New %s', $this->post_singular ),
167 | 'view_item' => sprintf( 'View %s', $this->post_singular ),
168 | 'view_items' => sprintf( 'View %s', $this->post_plural ),
169 | 'search_items' => sprintf( 'Search %s', $this->post_plural ),
170 | 'not_found' => sprintf( 'No %s found.', $this->post_plural_low ),
171 | 'not_found_in_trash' => sprintf( 'No %s found in trash.', $this->post_plural_low ),
172 | 'parent_item_colon' => sprintf( 'Parent %s:', $this->post_singular ),
173 | 'all_items' => sprintf( 'All %s', $this->post_plural ),
174 | 'archives' => sprintf( '%s Archives', $this->post_singular ),
175 | 'attributes' => sprintf( '%s Attributes', $this->post_singular ),
176 | 'insert_into_item' => sprintf( 'Insert into %s', $this->post_singular_low ),
177 | 'uploaded_to_this_item' => sprintf( 'Uploaded to this %s', $this->post_singular_low ),
178 | 'filter_items_list' => sprintf( 'Filter %s list', $this->post_plural_low ),
179 | 'filter_by_date' => 'Filter by date',
180 | 'items_list_navigation' => sprintf( '%s list navigation', $this->post_plural ),
181 | 'items_list' => sprintf( '%s list', $this->post_plural ),
182 | 'item_published' => sprintf( '%s published.', $this->post_singular ),
183 | 'item_published_privately' => sprintf( '%s published privately.', $this->post_singular ),
184 | 'item_reverted_to_draft' => sprintf( '%s reverted to draft.', $this->post_singular ),
185 | 'item_scheduled' => sprintf( '%s scheduled.', $this->post_singular ),
186 | 'item_updated' => sprintf( '%s updated.', $this->post_singular ),
187 | 'item_link' => sprintf( '%s Link', $this->post_singular ),
188 | 'item_link_description' => sprintf( 'A link to a %s.', $this->post_singular_low ),
189 | 'item_trashed' => sprintf( '%s trashed.', $this->post_singular ),
190 | 'template_name' => sprintf( 'Single item: %s', $this->post_singular ),
191 | ];
192 |
193 | # Build the featured image labels:
194 | if ( isset( $args['featured_image'] ) ) {
195 | $featured_image_low = strtolower( $args['featured_image'] );
196 | $this->defaults['labels']['featured_image'] = $args['featured_image'];
197 | $this->defaults['labels']['set_featured_image'] = sprintf( 'Set %s', $featured_image_low );
198 | $this->defaults['labels']['remove_featured_image'] = sprintf( 'Remove %s', $featured_image_low );
199 | $this->defaults['labels']['use_featured_image'] = sprintf( 'Use as %s', $featured_image_low );
200 | }
201 |
202 | # Only set default rewrites if we need them
203 | if ( isset( $args['public'] ) && ! $args['public'] ) {
204 | $this->defaults['rewrite'] = false;
205 | } else {
206 | $this->defaults['rewrite'] = [
207 | 'slug' => $this->post_slug,
208 | 'with_front' => false,
209 | ];
210 | }
211 |
212 | # Merge our args with the defaults:
213 | $this->args = array_merge( $this->defaults, $args );
214 |
215 | # This allows the 'labels' and 'rewrite' args to contain all, some, or no values:
216 | foreach ( [ 'labels', 'rewrite' ] as $arg ) {
217 | if ( isset( $args[ $arg ] ) && is_array( $args[ $arg ] ) && is_array( $this->defaults[ $arg ] ) ) {
218 | $this->args[ $arg ] = array_merge( $this->defaults[ $arg ], $args[ $arg ] );
219 | }
220 | }
221 |
222 | # Enable post type archives by default
223 | if ( ! isset( $this->args['has_archive'] ) ) {
224 | $this->args['has_archive'] = $this->args['public'];
225 | }
226 | }
227 |
228 | /**
229 | * Initialise the post type by adding the necessary actions and filters.
230 | */
231 | public function init(): void {
232 | # Front-end sortables:
233 | if ( $this->args['site_sortables'] && ! is_admin() ) {
234 | add_action( 'pre_get_posts', [ $this, 'maybe_sort_by_fields' ] );
235 | add_filter( 'posts_clauses', [ $this, 'maybe_sort_by_taxonomy' ], 10, 2 );
236 | }
237 |
238 | # Front-end filters:
239 | if ( $this->args['site_filters'] && ! is_admin() ) {
240 | add_action( 'pre_get_posts', [ $this, 'maybe_filter' ] );
241 | add_filter( 'query_vars', [ $this, 'add_query_vars' ] );
242 | }
243 |
244 | # Post type in the site's main feed:
245 | if ( $this->args['show_in_feed'] ) {
246 | add_filter( 'request', [ $this, 'add_to_feed' ] );
247 | }
248 |
249 | # Post type archive query vars:
250 | if ( $this->args['archive'] && ! is_admin() ) {
251 | add_filter( 'parse_request', [ $this, 'override_private_query_vars' ], 1 );
252 | }
253 |
254 | # Custom post type permastruct:
255 | if ( $this->args['rewrite'] && ! empty( $this->args['rewrite']['permastruct'] ) ) {
256 | add_action( 'registered_post_type', [ $this, 'registered_post_type' ], 1, 2 );
257 | add_filter( 'post_type_link', [ $this, 'post_type_link' ], 1, 2 );
258 | }
259 |
260 | # Rewrite testing:
261 | if ( $this->args['rewrite'] ) {
262 | add_filter( 'rewrite_testing_tests', [ $this, 'rewrite_testing_tests' ], 1 );
263 | }
264 |
265 | # Register post type:
266 | $this->register_post_type();
267 |
268 | /**
269 | * Fired when the extended post type instance is set up.
270 | *
271 | * @since 3.1.0
272 | *
273 | * @param \ExtCPTs\PostType $instance The extended post type instance.
274 | */
275 | do_action( "ext-cpts/{$this->post_type}/instance", $this );
276 | }
277 |
278 | /**
279 | * Set the relevant query vars for filtering posts by our front-end filters.
280 | *
281 | * @param WP_Query $wp_query The current WP_Query object.
282 | */
283 | public function maybe_filter( WP_Query $wp_query ): void {
284 | if ( empty( $wp_query->query['post_type'] ) || ! in_array( $this->post_type, (array) $wp_query->query['post_type'], true ) ) {
285 | return;
286 | }
287 |
288 | $vars = self::get_filter_vars( $wp_query->query, $this->args['site_filters'], $this->post_type );
289 |
290 | if ( empty( $vars ) ) {
291 | return;
292 | }
293 |
294 | foreach ( $vars as $key => $value ) {
295 | if ( is_array( $value ) ) {
296 | $query = $wp_query->get( $key );
297 | if ( empty( $query ) ) {
298 | $query = [];
299 | }
300 | $value = array_merge( $query, $value );
301 | }
302 | $wp_query->set( $key, $value );
303 | }
304 | }
305 |
306 | /**
307 | * Set the relevant query vars for sorting posts by our front-end sortables.
308 | *
309 | * @param WP_Query $wp_query The current WP_Query object.
310 | */
311 | public function maybe_sort_by_fields( WP_Query $wp_query ): void {
312 | if ( empty( $wp_query->query['post_type'] ) || ! in_array( $this->post_type, (array) $wp_query->query['post_type'], true ) ) {
313 | return;
314 | }
315 |
316 | // If we've not specified an order:
317 | if ( empty( $wp_query->query['orderby'] ) ) {
318 | // Loop over our sortables to find the default sort field (if there is one):
319 | foreach ( $this->args['site_sortables'] as $id => $col ) {
320 | if ( is_array( $col ) && isset( $col['default'] ) ) {
321 | // @TODO Don't set 'order' if 'orderby' is an array (WP 4.0+)
322 | $wp_query->query['orderby'] = $id;
323 | $wp_query->query['order'] = ( 'desc' === strtolower( $col['default'] ) ? 'desc' : 'asc' );
324 | break;
325 | }
326 | }
327 | }
328 |
329 | $sort = self::get_sort_field_vars( $wp_query->query, $this->args['site_sortables'] );
330 |
331 | if ( empty( $sort ) ) {
332 | return;
333 | }
334 |
335 | foreach ( $sort as $key => $value ) {
336 | $wp_query->set( $key, $value );
337 | }
338 | }
339 |
340 | /**
341 | * Filter the query's SQL clauses so we can sort posts by taxonomy terms.
342 | *
343 | * @param array $clauses Array of the current query's SQL clauses.
344 | * @param WP_Query $wp_query The current `WP_Query` object.
345 | * @return array Array of SQL clauses.
346 | */
347 | public function maybe_sort_by_taxonomy( array $clauses, WP_Query $wp_query ): array {
348 | if ( empty( $wp_query->query['post_type'] ) || ! in_array( $this->post_type, (array) $wp_query->query['post_type'], true ) ) {
349 | return $clauses;
350 | }
351 |
352 | $sort = self::get_sort_taxonomy_clauses( $clauses, $wp_query->query, $this->args['site_sortables'] );
353 |
354 | if ( empty( $sort ) ) {
355 | return $clauses;
356 | }
357 |
358 | return array_merge( $clauses, $sort );
359 | }
360 |
361 | /**
362 | * Get the array of private query vars for the given filters, to apply to the current query in order to filter it by the
363 | * given public query vars.
364 | *
365 | * @param array $query The public query vars, usually from `$wp_query->query`.
366 | * @param array $filters The filters valid for this query (usually the value of the `admin_filters` or
367 | * `site_filters` argument when registering an extended post type).
368 | * @param string $post_type The post type name.
369 | * @return array The list of private query vars to apply to the query.
370 | */
371 | public static function get_filter_vars( array $query, array $filters, string $post_type ): array {
372 | $return = [];
373 |
374 | foreach ( $filters as $filter_key => $filter ) {
375 | $meta_query = [];
376 | $date_query = [];
377 |
378 | if ( ! isset( $query[ $filter_key ] ) || ( '' === $query[ $filter_key ] ) ) {
379 | continue;
380 | }
381 |
382 | if ( isset( $filter['cap'] ) && ! current_user_can( $filter['cap'] ) ) {
383 | continue;
384 | }
385 |
386 | $hook = "ext-cpts/{$post_type}/filter-query/{$filter_key}";
387 |
388 | if ( has_filter( $hook ) ) {
389 | /**
390 | * Allows a filter's private query vars to be overridden.
391 | *
392 | * @since 4.3.0
393 | *
394 | * @param array $return The private query vars.
395 | * @param array $query The public query vars.
396 | * @param array $filter The filter arguments.
397 | */
398 | $return = apply_filters( $hook, $return, $query, $filter );
399 | continue;
400 | }
401 |
402 | if ( isset( $filter['meta_key'] ) ) {
403 | $meta_query = [
404 | 'key' => $filter['meta_key'],
405 | 'value' => wp_unslash( $query[ $filter_key ] ),
406 | ];
407 | } elseif ( isset( $filter['meta_search_key'] ) ) {
408 | $meta_query = [
409 | 'key' => $filter['meta_search_key'],
410 | 'value' => wp_unslash( $query[ $filter_key ] ),
411 | 'compare' => 'LIKE',
412 | ];
413 | } elseif ( isset( $filter['meta_key_exists'] ) ) {
414 | $meta_query = [
415 | 'key' => wp_unslash( $query[ $filter_key ] ),
416 | 'compare' => 'EXISTS',
417 | ];
418 | } elseif ( isset( $filter['meta_exists'] ) ) {
419 | $meta_query = [
420 | 'key' => wp_unslash( $query[ $filter_key ] ),
421 | 'compare' => 'NOT IN',
422 | 'value' => [ '', '0', 'false', 'null' ],
423 | ];
424 | } elseif ( isset( $filter['post_date'] ) ) {
425 | $date_query = [
426 | $filter['post_date'] => wp_unslash( $query[ $filter_key ] ),
427 | 'inclusive' => true,
428 | ];
429 | } else {
430 | continue;
431 | }
432 |
433 | if ( isset( $filter['meta_query'] ) ) {
434 | $meta_query = array_merge( $meta_query, $filter['meta_query'] );
435 | }
436 |
437 | if ( isset( $filter['date_query'] ) ) {
438 | $date_query = array_merge( $date_query, $filter['date_query'] );
439 | }
440 |
441 | if ( ! empty( $meta_query ) ) {
442 | $return['meta_query'][] = $meta_query;
443 | }
444 |
445 | if ( ! empty( $date_query ) ) {
446 | $return['date_query'][] = $date_query;
447 | }
448 | }
449 |
450 | return $return;
451 | }
452 |
453 | /**
454 | * Get the array of private and public query vars for the given sortables, to apply to the current query in order to
455 | * sort it by the requested orderby field.
456 | *
457 | * @param array $vars The public query vars, usually from `$wp_query->query`.
458 | * @param array $sortables The sortables valid for this query (usually the value of the `admin_cols` or
459 | * `site_sortables` argument when registering an extended post type.
460 | * @return array The list of private and public query vars to apply to the query.
461 | */
462 | public static function get_sort_field_vars( array $vars, array $sortables ): array {
463 | if ( ! isset( $vars['orderby'] ) ) {
464 | return [];
465 | }
466 |
467 | if ( ! is_string( $vars['orderby'] ) ) {
468 | return [];
469 | }
470 |
471 | if ( ! isset( $sortables[ $vars['orderby'] ] ) ) {
472 | return [];
473 | }
474 |
475 | $orderby = $sortables[ $vars['orderby'] ];
476 |
477 | if ( ! is_array( $orderby ) ) {
478 | return [];
479 | }
480 |
481 | if ( isset( $orderby['sortable'] ) && ! $orderby['sortable'] ) {
482 | return [];
483 | }
484 |
485 | $return = [];
486 |
487 | if ( isset( $orderby['meta_key'] ) ) {
488 | $return['meta_key'] = $orderby['meta_key'];
489 | $return['orderby'] = 'meta_value';
490 | // @TODO meta_value_num
491 | } elseif ( isset( $orderby['post_field'] ) ) {
492 | $field = str_replace( 'post_', '', $orderby['post_field'] );
493 | $return['orderby'] = $field;
494 | }
495 |
496 | if ( isset( $vars['order'] ) ) {
497 | $return['order'] = $vars['order'];
498 | }
499 |
500 | return $return;
501 | }
502 |
503 | /**
504 | * Get the array of SQL clauses for the given sortables, to apply to the current query in order to
505 | * sort it by the requested orderby field.
506 | *
507 | * @param array $clauses The query's SQL clauses.
508 | * @param array $vars The public query vars, usually from `$wp_query->query`.
509 | * @param array $sortables The sortables valid for this query (usually the value of the `admin_cols` or
510 | * `site_sortables` argument when registering an extended post type).
511 | * @return array The list of SQL clauses to apply to the query.
512 | */
513 | public static function get_sort_taxonomy_clauses( array $clauses, array $vars, array $sortables ): array {
514 | global $wpdb;
515 |
516 | if ( ! isset( $vars['orderby'] ) ) {
517 | return [];
518 | }
519 |
520 | if ( ! is_string( $vars['orderby'] ) ) {
521 | return [];
522 | }
523 |
524 | if ( ! isset( $sortables[ $vars['orderby'] ] ) ) {
525 | return [];
526 | }
527 |
528 | $orderby = $sortables[ $vars['orderby'] ];
529 |
530 | if ( ! is_array( $orderby ) ) {
531 | return [];
532 | }
533 |
534 | if ( isset( $orderby['sortable'] ) && ! $orderby['sortable'] ) {
535 | return [];
536 | }
537 |
538 | if ( ! isset( $orderby['taxonomy'] ) ) {
539 | return [];
540 | }
541 |
542 | # Taxonomy term ordering courtesy of http://scribu.net/wordpress/sortable-taxonomy-columns.html
543 | $clauses['join'] .= "
544 | LEFT OUTER JOIN {$wpdb->term_relationships} as ext_cpts_tr
545 | ON ( {$wpdb->posts}.ID = ext_cpts_tr.object_id )
546 | LEFT OUTER JOIN {$wpdb->term_taxonomy} as ext_cpts_tt
547 | ON ( ext_cpts_tr.term_taxonomy_id = ext_cpts_tt.term_taxonomy_id )
548 | LEFT OUTER JOIN {$wpdb->terms} as ext_cpts_t
549 | ON ( ext_cpts_tt.term_id = ext_cpts_t.term_id )
550 | ";
551 | $clauses['where'] .= $wpdb->prepare( ' AND ( taxonomy = %s OR taxonomy IS NULL )', $orderby['taxonomy'] );
552 | $clauses['groupby'] = 'ext_cpts_tr.object_id';
553 | $clauses['orderby'] = 'GROUP_CONCAT( ext_cpts_t.name ORDER BY name ASC ) ';
554 | $clauses['orderby'] .= ( isset( $vars['order'] ) && ( 'ASC' === strtoupper( $vars['order'] ) ) ) ? 'ASC' : 'DESC';
555 |
556 | return $clauses;
557 | }
558 |
559 | /**
560 | * Add our filter names to the public query vars.
561 | *
562 | * @param array $vars Public query variables.
563 | * @return array Updated public query variables.
564 | */
565 | public function add_query_vars( array $vars ): array {
566 | /** @var array */
567 | $site_filters = $this->args['site_filters'];
568 | $filters = array_keys( $site_filters );
569 |
570 | return array_merge( $vars, $filters );
571 | }
572 |
573 | /**
574 | * Add our post type to the feed.
575 | *
576 | * @param array $vars Request parameters.
577 | * @return array Updated request parameters.
578 | */
579 | public function add_to_feed( array $vars ): array {
580 | # If it's not a feed, we're not interested:
581 | if ( ! isset( $vars['feed'] ) ) {
582 | return $vars;
583 | }
584 |
585 | if ( ! isset( $vars['post_type'] ) ) {
586 | $vars['post_type'] = [
587 | 'post',
588 | $this->post_type,
589 | ];
590 | } elseif ( is_array( $vars['post_type'] ) && ( count( $vars['post_type'] ) > 1 ) ) {
591 | $vars['post_type'][] = $this->post_type;
592 | }
593 |
594 | return $vars;
595 | }
596 |
597 | /**
598 | * Add to or override our post type archive's private query vars.
599 | *
600 | * @param WP $wp The WP request object.
601 | * @return WP Updated WP request object.
602 | */
603 | public function override_private_query_vars( WP $wp ): WP {
604 | # If it's not our post type, bail out:
605 | if ( ! isset( $wp->query_vars['post_type'] ) || ( $this->post_type !== $wp->query_vars['post_type'] ) ) {
606 | return $wp;
607 | }
608 |
609 | # If it's a single post, bail out:
610 | if ( isset( $wp->query_vars['name'] ) ) {
611 | return $wp;
612 | }
613 |
614 | # Set the vars:
615 | foreach ( $this->args['archive'] as $var => $value ) {
616 | $wp->query_vars[ $var ] = $value;
617 | }
618 |
619 | return $wp;
620 | }
621 |
622 | /**
623 | * Action fired after a PostType is registered in order to set up the custom permalink structure for the post type.
624 | *
625 | * @param string $post_type Post type name.
626 | * @param WP_Post_Type $post_type_object Post type object.
627 | */
628 | public function registered_post_type( string $post_type, WP_Post_Type $post_type_object ): void {
629 | if ( $post_type !== $this->post_type ) {
630 | return;
631 | }
632 | if ( ! $post_type_object->rewrite ) {
633 | return;
634 | }
635 | if ( ! is_string( $post_type_object->rewrite['permastruct'] ) ) {
636 | return;
637 | }
638 |
639 | $struct = str_replace( "%{$this->post_type}_slug%", $this->post_slug, $post_type_object->rewrite['permastruct'] );
640 | $struct = str_replace( '%postname%', "%{$this->post_type}%", $struct );
641 |
642 | add_permastruct( $this->post_type, $struct, $post_type_object->rewrite );
643 | }
644 |
645 | /**
646 | * Filter the post type permalink in order to populate its rewrite tags.
647 | *
648 | * @param string $post_link The post's permalink.
649 | * @param WP_Post $post The post in question.
650 | * @return string The post's permalink.
651 | */
652 | public function post_type_link( string $post_link, WP_Post $post ): string {
653 | # If it's not our post type, bail out:
654 | if ( $this->post_type !== $post->post_type ) {
655 | return $post_link;
656 | }
657 |
658 | /** @var string */
659 | $date = mysql2date( 'Y m d H i s', $post->post_date );
660 | $date = explode( ' ', $date );
661 | $replacements = [
662 | '%year%' => $date[0],
663 | '%monthnum%' => $date[1],
664 | '%day%' => $date[2],
665 | '%hour%' => $date[3],
666 | '%minute%' => $date[4],
667 | '%second%' => $date[5],
668 | '%post_id%' => $post->ID,
669 | ];
670 |
671 | if ( false !== strpos( $post_link, '%author%' ) ) {
672 | $author = get_userdata( (int) $post->post_author );
673 | if ( $author ) {
674 | $replacements['%author%'] = $author->user_nicename;
675 | } else {
676 | $replacements['%author%'] = '-';
677 | }
678 | }
679 |
680 | /** @var string $tax */
681 | foreach ( get_object_taxonomies( $post ) as $tax ) {
682 | if ( false === strpos( $post_link, "%{$tax}%" ) ) {
683 | continue;
684 | }
685 |
686 | $terms = get_the_terms( $post, $tax );
687 |
688 | if ( $terms && ! is_wp_error( $terms ) ) {
689 | /**
690 | * Filter the term that gets used in the `$tax` permalink token.
691 | *
692 | * @param WP_Term $term The `$tax` term to use in the permalink.
693 | * @param WP_Term[] $terms Array of all `$tax` terms associated with the post.
694 | * @param WP_Post $post The post in question.
695 | */
696 | $term_object = apply_filters( "post_link_{$tax}", reset( $terms ), $terms, $post );
697 |
698 | $term = $term_object->slug;
699 |
700 | } else {
701 | $term = $post->post_type;
702 |
703 | /**
704 | * Filter the default term that gets used in the `$tax` permalink token.
705 | *
706 | * @param int $term The ID of the term to use in the permalink.
707 | * @param WP_Post $post The post in question.
708 | */
709 | $default_term_id = (int) apply_filters( "default_{$tax}", get_option( "default_{$tax}", 0 ), $post );
710 |
711 | if ( $default_term_id ) {
712 | $default_term = get_term( $default_term_id, $tax );
713 | if ( $default_term instanceof WP_Term ) {
714 | $term = $default_term->slug;
715 | }
716 | }
717 | }
718 |
719 | $replacements[ "%{$tax}%" ] = $term;
720 | }
721 |
722 | $post_link = str_replace( array_keys( $replacements ), $replacements, $post_link );
723 |
724 | return $post_link;
725 | }
726 |
727 | /**
728 | * Add our rewrite tests to the Rewrite Rule Testing tests array.
729 | *
730 | * @codeCoverageIgnore
731 | *
732 | * @param array> $tests The existing rewrite rule tests.
733 | * @return array> Updated rewrite rule tests.
734 | */
735 | public function rewrite_testing_tests( array $tests ): array {
736 | require_once __DIR__ . '/ExtendedRewriteTesting.php';
737 | require_once __DIR__ . '/PostTypeRewriteTesting.php';
738 |
739 | $extended = new PostTypeRewriteTesting( $this );
740 |
741 | return array_merge( $tests, $extended->get_tests() );
742 | }
743 |
744 | /**
745 | * Registers our post type.
746 | *
747 | * The only difference between this and regular `register_post_type()` calls is this will trigger an error of
748 | * `E_USER_ERROR` level if a `WP_Error` is returned.
749 | *
750 | */
751 | public function register_post_type(): void {
752 | if ( ! isset( $this->args['query_var'] ) || ( true === $this->args['query_var'] ) ) {
753 | $query_var = $this->post_type;
754 | } else {
755 | $query_var = $this->args['query_var'];
756 | }
757 |
758 | $existing = get_post_type_object( $this->post_type );
759 | $taxonomies = get_taxonomies(
760 | [
761 | 'query_var' => $query_var,
762 | ],
763 | 'objects'
764 | );
765 |
766 | if ( $query_var && count( $taxonomies ) ) {
767 | // https://core.trac.wordpress.org/ticket/35089
768 | foreach ( $taxonomies as $tax ) {
769 | if ( $tax->query_var === $query_var ) {
770 | trigger_error(
771 | esc_html(
772 | sprintf(
773 | /* translators: %s: Post type query variable name */
774 | __( 'Post type query var "%s" clashes with a taxonomy query var of the same name', 'extended-cpts' ),
775 | $query_var
776 | )
777 | ),
778 | E_USER_ERROR
779 | );
780 | }
781 | }
782 | }
783 |
784 | if ( empty( $existing ) ) {
785 | $cpt = register_post_type( $this->post_type, $this->args );
786 |
787 | if ( is_wp_error( $cpt ) ) {
788 | trigger_error( esc_html( $cpt->get_error_message() ), E_USER_ERROR );
789 | }
790 | } else {
791 | # This allows us to call `register_extended_post_type()` on an existing post type to add custom functionality
792 | # to the post type.
793 | $this->extend( $existing );
794 | }
795 | }
796 |
797 | /**
798 | * Extends an existing post type object. Currently only handles labels.
799 | *
800 | * @param WP_Post_Type $pto A post type object.
801 | */
802 | public function extend( WP_Post_Type $pto ): void {
803 | # Merge core with overridden labels
804 | $this->args['labels'] = array_merge( (array) get_post_type_labels( $pto ), $this->args['labels'] );
805 |
806 | $GLOBALS['wp_post_types'][ $pto->name ]->labels = (object) $this->args['labels'];
807 | }
808 |
809 | /**
810 | * Helper function for registering a taxonomy and adding it to this post type.
811 | *
812 | * Accepts the same parameters as `register_extended_taxonomy()`, minus the `$object_type` parameter.
813 | *
814 | * Example usage:
815 | *
816 | * $events = register_extended_post_type( 'event' );
817 | * $location = $events->add_taxonomy( 'location' );
818 | *
819 | * @param string $taxonomy The taxonomy name.
820 | * @param array $args Optional. The taxonomy arguments.
821 | * @param array $names Optional. An associative array of the plural, singular, and slug names.
822 | * @return WP_Taxonomy Taxonomy object.
823 | */
824 | public function add_taxonomy( string $taxonomy, array $args = [], array $names = [] ): WP_Taxonomy {
825 | if ( taxonomy_exists( $taxonomy ) ) {
826 | register_taxonomy_for_object_type( $taxonomy, $this->post_type );
827 | } else {
828 | register_extended_taxonomy( $taxonomy, $this->post_type, $args, $names );
829 | }
830 |
831 | /** @var WP_Taxonomy */
832 | $tax = get_taxonomy( $taxonomy );
833 |
834 | return $tax;
835 | }
836 |
837 | }
838 |
--------------------------------------------------------------------------------
/src/PostTypeAdmin.php:
--------------------------------------------------------------------------------
1 |
20 | */
21 | protected array $defaults = [
22 | 'quick_edit' => true, # Custom arg
23 | 'dashboard_glance' => true, # Custom arg
24 | 'dashboard_activity' => true, # Custom arg
25 | 'admin_cols' => null, # Custom arg
26 | 'admin_filters' => null, # Custom arg
27 | 'enter_title_here' => null, # Custom arg
28 | 'block_editor' => null, # Custom arg
29 | ];
30 |
31 | public PostType $cpt;
32 |
33 | /**
34 | * @var array
35 | */
36 | public array $args;
37 |
38 | /**
39 | * @var array
40 | */
41 | protected array $_cols;
42 |
43 | /**
44 | * @var array
45 | */
46 | protected ?array $the_cols = null;
47 |
48 | /**
49 | * @var array
50 | */
51 | protected array $connection_exists = [];
52 |
53 | /**
54 | * Class constructor.
55 | *
56 | * @param PostType $cpt An extended post type object.
57 | * @param array $args Optional. The post type arguments.
58 | */
59 | public function __construct( PostType $cpt, array $args = [] ) {
60 | $this->cpt = $cpt;
61 | # Merge our args with the defaults:
62 | $this->args = array_merge( $this->defaults, $args );
63 | }
64 |
65 | /**
66 | * Initialise the admin features of the post type by adding the necessary actions and filters.
67 | */
68 | public function init(): void {
69 | # Admin columns:
70 | if ( $this->args['admin_cols'] ) {
71 | add_filter( 'manage_posts_columns', [ $this, '_log_default_cols' ], 0 );
72 | add_filter( 'manage_pages_columns', [ $this, '_log_default_cols' ], 0 );
73 | add_filter( 'manage_media_columns', [ $this, '_log_default_cols' ], 0 );
74 | if ( 'attachment' === $this->cpt->post_type ) {
75 | add_filter( 'manage_upload_sortable_columns', [ $this, 'sortables' ] );
76 | add_filter( 'manage_media_columns', [ $this, 'cols' ] );
77 | add_action( 'manage_media_custom_column', [ $this, 'col' ], 10, 2 );
78 | } else {
79 | add_filter( "manage_edit-{$this->cpt->post_type}_sortable_columns", [ $this, 'sortables' ] );
80 | add_filter( "manage_{$this->cpt->post_type}_posts_columns", [ $this, 'cols' ] );
81 | add_action( "manage_{$this->cpt->post_type}_posts_custom_column", [ $this, 'col' ], 10, 2 );
82 | }
83 | add_action( 'load-edit.php', [ $this, 'default_sort' ] );
84 | add_action( 'pre_get_posts', [ $this, 'maybe_sort_by_fields' ] );
85 | add_filter( 'posts_clauses', [ $this, 'maybe_sort_by_taxonomy' ], 10, 2 );
86 | }
87 |
88 | # Admin filters:
89 | if ( $this->args['admin_filters'] ) {
90 | add_action( 'load-edit.php', [ $this, 'default_filter' ] );
91 | add_action( 'pre_get_posts', [ $this, 'maybe_filter' ] );
92 | add_filter( 'query_vars', [ $this, 'add_query_vars' ] );
93 | add_action( 'restrict_manage_posts', [ $this, 'filters' ] );
94 | }
95 |
96 | # 'Enter title here' filter:
97 | if ( $this->args['enter_title_here'] ) {
98 | add_filter( 'enter_title_here', [ $this, 'enter_title_here' ], 10, 2 );
99 | }
100 |
101 | # Block editor filter:
102 | if ( ! is_null( $this->args['block_editor'] ) && is_bool( $this->args['block_editor'] ) ) {
103 | add_filter( 'use_block_editor_for_post_type', [ $this, 'block_editor' ], 101, 2 );
104 | }
105 |
106 | # Hide month filter:
107 | if ( isset( $this->args['admin_filters']['m'] ) && ! $this->args['admin_filters']['m'] ) {
108 | add_filter( 'disable_months_dropdown', [ $this, 'filter_disable_months_dropdown' ], 10, 2 );
109 | }
110 |
111 | # Quick Edit:
112 | if ( ! $this->args['quick_edit'] ) {
113 | add_filter( 'post_row_actions', [ $this, 'remove_quick_edit_action' ], 10, 2 );
114 | add_filter( 'page_row_actions', [ $this, 'remove_quick_edit_action' ], 10, 2 );
115 | add_filter( "bulk_actions-edit-{$this->cpt->post_type}", [ $this, 'remove_quick_edit_menu' ] );
116 | }
117 |
118 | # 'At a Glance' dashboard panels:
119 | if ( $this->args['dashboard_glance'] ) {
120 | add_filter( 'dashboard_glance_items', [ $this, 'glance_items' ], $this->cpt->args['menu_position'] );
121 | }
122 |
123 | # 'Recently Published' dashboard section:
124 | if ( $this->args['dashboard_activity'] ) {
125 | add_filter( 'dashboard_recent_posts_query_args', [ $this, 'dashboard_activity' ] );
126 | }
127 |
128 | # Post updated messages:
129 | add_filter( 'post_updated_messages', [ $this, 'post_updated_messages' ], 1 );
130 | add_filter( 'bulk_post_updated_messages', [ $this, 'bulk_post_updated_messages' ], 1, 2 );
131 |
132 | /**
133 | * Fired when the extended post type admin instance is set up.
134 | *
135 | * @since 5.0.0
136 | *
137 | * @param \ExtCPTs\PostTypeAdmin $instance The extended post type admin instance.
138 | */
139 | do_action( "ext-cpts/{$this->cpt->post_type}/admin-instance", $this );
140 | }
141 |
142 | /**
143 | * Removes the default 'Months' drop-down from the post list table.
144 | *
145 | * @param bool $disable Whether to disable the drop-down.
146 | * @param string $post_type The post type.
147 | * @return bool Whether to disable the drop-down.
148 | */
149 | public function filter_disable_months_dropdown( bool $disable, string $post_type ): bool {
150 | if ( $post_type === $this->cpt->post_type ) {
151 | return true;
152 | }
153 |
154 | return $disable;
155 | }
156 |
157 | /**
158 | * Sets the default sort field and sort order on our post type admin screen.
159 | */
160 | public function default_sort(): void {
161 | if ( self::get_current_post_type() !== $this->cpt->post_type ) {
162 | return;
163 | }
164 |
165 | # If we've already ordered the screen, bail out:
166 | if ( isset( $_GET['orderby'] ) ) {
167 | return;
168 | }
169 |
170 | # Loop over our columns to find the default sort column (if there is one):
171 | foreach ( $this->args['admin_cols'] as $id => $col ) {
172 | if ( is_array( $col ) && isset( $col['default'] ) ) {
173 | $_GET['orderby'] = $id;
174 | $_GET['order'] = ( 'desc' === strtolower( $col['default'] ) ? 'desc' : 'asc' );
175 | break;
176 | }
177 | }
178 | }
179 |
180 | /**
181 | * Sets the default sort field and sort order on our post type admin screen.
182 | */
183 | public function default_filter(): void {
184 | if ( self::get_current_post_type() !== $this->cpt->post_type ) {
185 | return;
186 | }
187 |
188 | # Loop over our filters to find the default filter (if there is one):
189 | foreach ( $this->args['admin_filters'] as $id => $filter ) {
190 | if ( isset( $_GET[ $id ] ) && '' !== $_GET[ $id ] ) {
191 | continue;
192 | }
193 |
194 | if ( is_array( $filter ) && isset( $filter['default'] ) ) {
195 | $_GET[ $id ] = $filter['default'];
196 | return;
197 | }
198 | }
199 | }
200 |
201 | /**
202 | * Sets the placeholder text for the title field for this post type.
203 | *
204 | * @param string $title The placeholder text.
205 | * @param WP_Post $post The current post.
206 | * @return string The updated placeholder text.
207 | */
208 | public function enter_title_here( string $title, WP_Post $post ): string {
209 | if ( $this->cpt->post_type !== $post->post_type ) {
210 | return $title;
211 | }
212 |
213 | return $this->args['enter_title_here'];
214 | }
215 |
216 | /**
217 | * Enable or disable the block editor if it matches this custom post type.
218 | *
219 | * @param bool $current_status The current status for the given post type.
220 | * @param string $post_type The current post type.
221 | * @return bool The updated status.
222 | */
223 | public function block_editor( bool $current_status, string $post_type ): bool {
224 | if ( $post_type === $this->cpt->post_type ) {
225 | return $this->args['block_editor'];
226 | }
227 |
228 | return $current_status;
229 | }
230 |
231 | /**
232 | * Returns the name of the post type for the current request.
233 | *
234 | * @return string The post type name.
235 | */
236 | protected static function get_current_post_type(): string {
237 | if ( function_exists( 'get_current_screen' ) && is_object( get_current_screen() ) && in_array( get_current_screen()->base, [ 'edit', 'upload' ], true ) ) {
238 | return get_current_screen()->post_type;
239 | } else {
240 | return '';
241 | }
242 | }
243 |
244 | /**
245 | * Outputs custom filter controls on the admin screen for this post type.
246 | *
247 | * @link https://github.com/johnbillion/extended-cpts/wiki/Admin-filters
248 | */
249 | public function filters(): void {
250 | global $wpdb;
251 |
252 | if ( self::get_current_post_type() !== $this->cpt->post_type ) {
253 | return;
254 | }
255 |
256 | /** @var \WP_Post_Type */
257 | $pto = get_post_type_object( $this->cpt->post_type );
258 |
259 | foreach ( $this->args['admin_filters'] as $filter_key => $filter ) {
260 | if ( isset( $filter['cap'] ) && ! current_user_can( $filter['cap'] ) ) {
261 | continue;
262 | }
263 |
264 | $id = 'filter_' . $filter_key;
265 |
266 | $hook = "ext-cpts/{$this->cpt->post_type}/filter-output/{$filter_key}";
267 |
268 | if ( has_action( $hook ) ) {
269 | /**
270 | * Allows a filter's output to be overridden.
271 | *
272 | * @since 4.3.0
273 | *
274 | * @param \ExtCPTs\PostTypeAdmin $controller The post type admin controller instance.
275 | * @param array $filter The filter arguments.
276 | * @param string $id The filter's `id` attribute value.
277 | */
278 | do_action( $hook, $this, $filter, $id );
279 | continue;
280 | }
281 |
282 | if ( isset( $filter['taxonomy'] ) ) {
283 | $tax = get_taxonomy( $filter['taxonomy'] );
284 |
285 | if ( empty( $tax ) ) {
286 | continue;
287 | }
288 |
289 | $walker = new Walker\Dropdown(
290 | [
291 | 'field' => 'slug',
292 | ]
293 | );
294 |
295 | # If we haven't specified a title, use the all_items label from the taxonomy:
296 | if ( ! isset( $filter['title'] ) ) {
297 | $filter['title'] = $tax->labels->all_items;
298 | }
299 |
300 | printf(
301 | '%2$s ',
302 | esc_attr( $id ),
303 | esc_html( $tax->labels->filter_by ?? $tax->labels->singular_name )
304 | );
305 |
306 | # Output the dropdown:
307 | wp_dropdown_categories(
308 | [
309 | 'show_option_all' => $filter['title'],
310 | 'hide_empty' => false,
311 | 'hide_if_empty' => true,
312 | 'hierarchical' => true,
313 | 'show_count' => false,
314 | 'orderby' => 'name',
315 | 'selected_cats' => $tax->query_var ? get_query_var( $tax->query_var ) : [],
316 | 'id' => $id,
317 | 'name' => (string) $tax->query_var,
318 | 'taxonomy' => $filter['taxonomy'],
319 | 'walker' => $walker,
320 | ]
321 | );
322 | } elseif ( isset( $filter['meta_key'] ) ) {
323 | # If we haven't specified a title, generate one from the meta key:
324 | if ( ! isset( $filter['title'] ) ) {
325 | $filter['title'] = str_replace(
326 | [
327 | '-',
328 | '_',
329 | ],
330 | ' ',
331 | $filter['meta_key']
332 | );
333 | $filter['title'] = ucwords( $filter['title'] ) . 's';
334 | $filter['title'] = sprintf( 'All %s', $filter['title'] );
335 | }
336 |
337 | # If we haven't specified a label, generate one from the meta key:
338 | if ( ! isset( $filter['label'] ) ) {
339 | $filter['label'] = str_replace(
340 | [
341 | '-',
342 | '_',
343 | ],
344 | ' ',
345 | $filter['meta_key']
346 | );
347 | $filter['label'] = ucwords( $filter['label'] );
348 | $filter['label'] = sprintf( 'Filter by %s', $filter['label'] );
349 | }
350 |
351 | if ( ! isset( $filter['options'] ) ) {
352 | # Fetch all the values for our meta key:
353 | $filter['options'] = $wpdb->get_col(
354 | $wpdb->prepare(
355 | "
356 | SELECT DISTINCT meta_value
357 | FROM {$wpdb->postmeta} as m
358 | JOIN {$wpdb->posts} as p ON ( p.ID = m.post_id )
359 | WHERE m.meta_key = %s
360 | AND m.meta_value != ''
361 | AND p.post_type = %s
362 | ORDER BY m.meta_value ASC
363 | ",
364 | $filter['meta_key'],
365 | $this->cpt->post_type
366 | )
367 | );
368 | } elseif ( is_callable( $filter['options'] ) ) {
369 | $filter['options'] = call_user_func( $filter['options'] );
370 | }
371 |
372 | if ( empty( $filter['options'] ) ) {
373 | continue;
374 | }
375 |
376 | $selected = wp_unslash( get_query_var( $filter_key ) );
377 |
378 | $use_key = false;
379 |
380 | foreach ( $filter['options'] as $k => $v ) {
381 | if ( ! is_numeric( $k ) ) {
382 | $use_key = true;
383 | break;
384 | }
385 | }
386 |
387 | printf(
388 | '%2$s ',
389 | esc_attr( $id ),
390 | esc_html( $filter['label'] )
391 | );
392 |
393 | # Output the dropdown:
394 | ?>
395 |
396 |
397 |
398 |
399 | $v ) {
401 | $key = ( $use_key ? $k : $v );
402 | ?>
403 | >
404 |
405 |
406 |
425 |
426 | labels->all_items;
431 | }
432 |
433 | $selected = wp_unslash( get_query_var( $filter_key ) );
434 | $fields = $filter['meta_exists'] ?? $filter['meta_key_exists'];
435 |
436 | if ( 1 === count( $fields ) ) {
437 | # Output a checkbox:
438 | foreach ( $fields as $v => $t ) {
439 | ?>
440 | >
441 | labels->name;
446 | }
447 |
448 | printf(
449 | '%2$s ',
450 | esc_attr( $id ),
451 | esc_html( $filter['label'] )
452 | );
453 |
454 | # Output a dropdown:
455 | ?>
456 |
457 |
458 |
459 |
460 | $t ) { ?>
461 | >
462 |
463 |
464 |
474 |
475 |
476 | %2$s',
490 | esc_attr( $id ),
491 | esc_html( $filter['label'] )
492 | );
493 |
494 | if ( ! isset( $filter['options'] ) ) {
495 | # Fetch all the values for our field:
496 | $filter['options'] = $wpdb->get_col(
497 | $wpdb->prepare(
498 | "
499 | SELECT DISTINCT post_author
500 | FROM {$wpdb->posts}
501 | WHERE post_type = %s
502 | ",
503 | $this->cpt->post_type
504 | )
505 | );
506 | } elseif ( is_callable( $filter['options'] ) ) {
507 | $filter['options'] = call_user_func( $filter['options'] );
508 | }
509 |
510 | if ( empty( $filter['options'] ) ) {
511 | continue;
512 | }
513 |
514 | # Output a dropdown:
515 | wp_dropdown_users(
516 | [
517 | 'id' => $id,
518 | 'include' => $filter['options'],
519 | 'name' => 'author',
520 | 'option_none_value' => '0',
521 | 'selected' => (int) $value,
522 | 'show_option_none' => $filter['title'],
523 | ]
524 | );
525 | }
526 | }
527 | }
528 |
529 | /**
530 | * Adds our filter names to the public query vars.
531 | *
532 | * @param array $vars Public query variables
533 | * @return array Updated public query variables
534 | */
535 | public function add_query_vars( array $vars ): array {
536 | /** @var array */
537 | $filters = array_keys( $this->args['admin_filters'] );
538 |
539 | return array_merge( $vars, $filters );
540 | }
541 |
542 | /**
543 | * Filters posts by our custom admin filters.
544 | *
545 | * @param WP_Query $wp_query A `WP_Query` object
546 | */
547 | public function maybe_filter( WP_Query $wp_query ): void {
548 | if ( empty( $wp_query->query['post_type'] ) || ! in_array( $this->cpt->post_type, (array) $wp_query->query['post_type'], true ) ) {
549 | return;
550 | }
551 |
552 | $vars = PostType::get_filter_vars( $wp_query->query, $this->cpt->args['admin_filters'], $this->cpt->post_type );
553 |
554 | if ( empty( $vars ) ) {
555 | return;
556 | }
557 |
558 | foreach ( $vars as $key => $value ) {
559 | if ( is_array( $value ) ) {
560 | $query = $wp_query->get( $key );
561 | if ( empty( $query ) ) {
562 | $query = [];
563 | }
564 | $value = array_merge( $query, $value );
565 | }
566 | $wp_query->set( $key, $value );
567 | }
568 | }
569 |
570 | /**
571 | * Sets the relevant query vars for sorting posts by our admin sortables.
572 | *
573 | * @param WP_Query $wp_query The current `WP_Query` object.
574 | */
575 | public function maybe_sort_by_fields( WP_Query $wp_query ): void {
576 | if ( empty( $wp_query->query['post_type'] ) || ! in_array( $this->cpt->post_type, (array) $wp_query->query['post_type'], true ) ) {
577 | return;
578 | }
579 |
580 | $sort = PostType::get_sort_field_vars( $wp_query->query, $this->cpt->args['admin_cols'] );
581 |
582 | if ( empty( $sort ) ) {
583 | return;
584 | }
585 |
586 | foreach ( $sort as $key => $value ) {
587 | $wp_query->set( $key, $value );
588 | }
589 | }
590 |
591 | /**
592 | * Filters the query's SQL clauses so we can sort posts by taxonomy terms.
593 | *
594 | * @param array $clauses The current query's SQL clauses.
595 | * @param WP_Query $wp_query The current `WP_Query` object.
596 | * @return array The updated SQL clauses.
597 | */
598 | public function maybe_sort_by_taxonomy( array $clauses, WP_Query $wp_query ): array {
599 | if ( empty( $wp_query->query['post_type'] ) || ! in_array( $this->cpt->post_type, (array) $wp_query->query['post_type'], true ) ) {
600 | return $clauses;
601 | }
602 |
603 | $sort = PostType::get_sort_taxonomy_clauses( $clauses, $wp_query->query, $this->cpt->args['admin_cols'] );
604 |
605 | if ( empty( $sort ) ) {
606 | return $clauses;
607 | }
608 |
609 | return array_merge( $clauses, $sort );
610 | }
611 |
612 | /**
613 | * Adds our post type to the 'At a Glance' widget on the dashboard.
614 | *
615 | * @param array $items Array of items to display on the widget.
616 | * @return array Updated array of items.
617 | */
618 | public function glance_items( array $items ): array {
619 | /** @var \WP_Post_Type */
620 | $pto = get_post_type_object( $this->cpt->post_type );
621 |
622 | if ( ! current_user_can( $pto->cap->edit_posts ) ) {
623 | return $items;
624 | }
625 | if ( $pto->_builtin ) {
626 | return $items;
627 | }
628 |
629 | # Get the labels and format the counts:
630 | /** @var \stdClass */
631 | $count = wp_count_posts( $this->cpt->post_type );
632 | $text = self::n( $pto->labels->singular_name, $pto->labels->name, (int) $count->publish );
633 | $num = number_format_i18n( $count->publish );
634 |
635 | # This is absolutely not localisable. WordPress 3.8 didn't add a new post type label.
636 | $url = add_query_arg(
637 | [
638 | 'post_type' => $this->cpt->post_type,
639 | ],
640 | admin_url( 'edit.php' )
641 | );
642 | $class = 'cpt-' . $this->cpt->post_type . '-count';
643 | $text = '' . esc_html( $num . ' ' . $text ) . ' ';
644 | $css = <<<'ICONCSS'
645 |
650 | ICONCSS;
651 |
652 | // Add styling to display the dashicon. This isn't possible without additional CSS rules.
653 | // https://core.trac.wordpress.org/ticket/33714
654 | // https://github.com/WordPress/dashicons/blob/master/codepoints.json
655 | if ( is_string( $pto->menu_icon ) && 0 === strpos( $pto->menu_icon, 'dashicons-' ) ) {
656 | $contents = file_get_contents( __DIR__ . '/dashicons-codepoints.json' );
657 | $codepoints = json_decode( $contents ?: '', true );
658 | $unprefixed = str_replace( 'dashicons-', '', $pto->menu_icon );
659 |
660 | if ( isset( $codepoints[ $unprefixed ] ) ) {
661 | $text .= sprintf(
662 | $css,
663 | esc_html( $class ),
664 | esc_html( dechex( $codepoints[ $unprefixed ] ) )
665 | );
666 | }
667 | }
668 |
669 | # Go!
670 | $items[] = $text;
671 |
672 | return $items;
673 | }
674 |
675 | /**
676 | * Adds our post type to the 'Recently Published' section on the dashboard.
677 | *
678 | * @param array $query_args Array of query args for the widget.
679 | * @return array Updated array of query args.
680 | */
681 | public function dashboard_activity( array $query_args ): array {
682 | $query_args['post_type'] = (array) $query_args['post_type'];
683 |
684 | $query_args['post_type'][] = $this->cpt->post_type;
685 |
686 | return $query_args;
687 | }
688 |
689 | /**
690 | * Adds our post type updated messages.
691 | *
692 | * The messages are as follows:
693 | *
694 | * 1 => "Post updated. {View Post}"
695 | * 2 => "Custom field updated."
696 | * 3 => "Custom field deleted."
697 | * 4 => "Post updated."
698 | * 5 => "Post restored to revision from [date]."
699 | * 6 => "Post published. {View post}"
700 | * 7 => "Post saved."
701 | * 8 => "Post submitted. {Preview post}"
702 | * 9 => "Post scheduled for: [date]. {Preview post}"
703 | * 10 => "Post draft updated. {Preview post}"
704 | *
705 | * @param array> $messages An array of post updated message arrays keyed by post type.
706 | * @return array> Updated array of post updated messages.
707 | */
708 | public function post_updated_messages( array $messages ): array {
709 | global $post;
710 |
711 | $pto = get_post_type_object( $this->cpt->post_type );
712 |
713 | if ( ! ( $pto instanceof \WP_Post_Type ) ) {
714 | return $messages;
715 | }
716 |
717 | $messages[ $this->cpt->post_type ] = [
718 | 1 => sprintf(
719 | ( $pto->publicly_queryable ? '%1$s updated. View %3$s ' : '%1$s updated.' ),
720 | esc_html( $this->cpt->post_singular ),
721 | esc_url( get_permalink( $post ) ),
722 | esc_html( $this->cpt->post_singular_low )
723 | ),
724 | 2 => 'Custom field updated.',
725 | 3 => 'Custom field deleted.',
726 | 4 => sprintf(
727 | '%s updated.',
728 | esc_html( $this->cpt->post_singular )
729 | ),
730 | 5 => isset( $_GET['revision'] ) ? sprintf(
731 | '%1$s restored to revision from %2$s',
732 | esc_html( $this->cpt->post_singular ),
733 | wp_post_revision_title( intval( $_GET['revision'] ), false )
734 | ) : false,
735 | 6 => sprintf(
736 | ( $pto->publicly_queryable ? '%1$s published. View %3$s ' : '%1$s published.' ),
737 | esc_html( $this->cpt->post_singular ),
738 | esc_url( get_permalink( $post ) ),
739 | esc_html( $this->cpt->post_singular_low )
740 | ),
741 | 7 => sprintf(
742 | '%s saved.',
743 | esc_html( $this->cpt->post_singular )
744 | ),
745 | 8 => sprintf(
746 | ( $pto->publicly_queryable ? '%1$s submitted. Preview %3$s ' : '%1$s submitted.' ),
747 | esc_html( $this->cpt->post_singular ),
748 | esc_url( get_preview_post_link( $post ) ),
749 | esc_html( $this->cpt->post_singular_low )
750 | ),
751 | 9 => sprintf(
752 | ( $pto->publicly_queryable ? '%1$s scheduled for: %2$s . Preview %4$s ' : '%1$s scheduled for: %2$s .' ),
753 | esc_html( $this->cpt->post_singular ),
754 | esc_html( date_i18n( 'M j, Y @ G:i', strtotime( $post->post_date ) ) ),
755 | esc_url( get_permalink( $post ) ),
756 | esc_html( $this->cpt->post_singular_low )
757 | ),
758 | 10 => sprintf(
759 | ( $pto->publicly_queryable ? '%1$s draft updated. Preview %3$s ' : '%1$s draft updated.' ),
760 | esc_html( $this->cpt->post_singular ),
761 | esc_url( get_preview_post_link( $post ) ),
762 | esc_html( $this->cpt->post_singular_low )
763 | ),
764 | ];
765 |
766 | return $messages;
767 | }
768 |
769 | /**
770 | * Adds our bulk post type updated messages.
771 | *
772 | * The messages are as follows:
773 | *
774 | * - updated => "Post updated." | "[n] posts updated."
775 | * - locked => "Post not updated, somebody is editing it." | "[n] posts not updated, somebody is editing them."
776 | * - deleted => "Post permanently deleted." | "[n] posts permanently deleted."
777 | * - trashed => "Post moved to the trash." | "[n] posts moved to the trash."
778 | * - untrashed => "Post restored from the trash." | "[n] posts restored from the trash."
779 | *
780 | * @param array> $messages An array of bulk post updated message arrays keyed by post type.
781 | * @param array $counts An array of counts for each key in `$messages`.
782 | * @return array> Updated array of bulk post updated messages.
783 | */
784 | public function bulk_post_updated_messages( array $messages, array $counts ): array {
785 | $messages[ $this->cpt->post_type ] = [
786 | 'updated' => sprintf(
787 | self::n( '%2$s updated.', '%1$s %3$s updated.', $counts['updated'] ),
788 | esc_html( number_format_i18n( $counts['updated'] ) ),
789 | esc_html( $this->cpt->post_singular ),
790 | esc_html( $this->cpt->post_plural_low )
791 | ),
792 | 'locked' => sprintf(
793 | self::n( '%2$s not updated, somebody is editing it.', '%1$s %3$s not updated, somebody is editing them.', $counts['locked'] ),
794 | esc_html( number_format_i18n( $counts['locked'] ) ),
795 | esc_html( $this->cpt->post_singular ),
796 | esc_html( $this->cpt->post_plural_low )
797 | ),
798 | 'deleted' => sprintf(
799 | self::n( '%2$s permanently deleted.', '%1$s %3$s permanently deleted.', $counts['deleted'] ),
800 | esc_html( number_format_i18n( $counts['deleted'] ) ),
801 | esc_html( $this->cpt->post_singular ),
802 | esc_html( $this->cpt->post_plural_low )
803 | ),
804 | 'trashed' => sprintf(
805 | self::n( '%2$s moved to the trash.', '%1$s %3$s moved to the trash.', $counts['trashed'] ),
806 | esc_html( number_format_i18n( $counts['trashed'] ) ),
807 | esc_html( $this->cpt->post_singular ),
808 | esc_html( $this->cpt->post_plural_low )
809 | ),
810 | 'untrashed' => sprintf(
811 | self::n( '%2$s restored from the trash.', '%1$s %3$s restored from the trash.', $counts['untrashed'] ),
812 | esc_html( number_format_i18n( $counts['untrashed'] ) ),
813 | esc_html( $this->cpt->post_singular ),
814 | esc_html( $this->cpt->post_plural_low )
815 | ),
816 | ];
817 |
818 | return $messages;
819 | }
820 |
821 | /**
822 | * Adds our custom columns to the list of sortable columns.
823 | *
824 | * @param array $cols Array of sortable columns keyed by the column ID.
825 | * @return array Updated array of sortable columns.
826 | */
827 | public function sortables( array $cols ): array {
828 | $admin_cols = $this->args['admin_cols'];
829 |
830 | /** @var array $admin_cols */
831 | foreach ( $admin_cols as $id => $col ) {
832 | if ( ! is_array( $col ) ) {
833 | continue;
834 | }
835 | if ( isset( $col['sortable'] ) && ! $col['sortable'] ) {
836 | continue;
837 | }
838 | if ( isset( $col['meta_key'] ) || isset( $col['taxonomy'] ) || isset( $col['post_field'] ) ) {
839 | $cols[ $id ] = $id;
840 | }
841 | }
842 |
843 | return $cols;
844 | }
845 |
846 | /**
847 | * Adds columns to the admin screen for this post type.
848 | *
849 | * @link https://github.com/johnbillion/extended-cpts/wiki/Admin-columns
850 | *
851 | * @param array $cols Associative array of columns
852 | * @return array Updated array of columns
853 | */
854 | public function cols( array $cols ): array {
855 | // This function gets called multiple times, so let's cache it for efficiency:
856 | if ( isset( $this->the_cols ) ) {
857 | return $this->the_cols;
858 | }
859 |
860 | $new_cols = [];
861 | $keep = [
862 | 'cb',
863 | 'title',
864 | ];
865 |
866 | # Add existing columns we want to keep:
867 | foreach ( $cols as $id => $title ) {
868 | if ( in_array( $id, $keep, true ) && ! isset( $this->args['admin_cols'][ $id ] ) ) {
869 | $new_cols[ $id ] = $title;
870 | }
871 | }
872 |
873 | /** @var array */
874 | $admin_cols = array_filter( $this->args['admin_cols'] );
875 |
876 | # Add our custom columns:
877 | foreach ( $admin_cols as $id => $col ) {
878 | if ( is_string( $col ) && isset( $cols[ $col ] ) ) {
879 | # Existing (ie. built-in) column with id as the value
880 | $new_cols[ $col ] = $cols[ $col ];
881 | } elseif ( is_string( $col ) && isset( $cols[ $id ] ) ) {
882 | # Existing (ie. built-in) column with id as the key and title as the value
883 | $new_cols[ $id ] = esc_html( $col );
884 | } elseif ( 'author' === $col ) {
885 | # Automatic support for Co-Authors Plus plugin and special case for
886 | # displaying author column when the post type doesn't support 'author'
887 | if ( class_exists( 'coauthors_plus' ) ) {
888 | $k = 'coauthors';
889 | } else {
890 | $k = 'author';
891 | }
892 | $new_cols[ $k ] = esc_html__( 'Author', 'extended-cpts' );
893 | } elseif ( is_array( $col ) ) {
894 | if ( isset( $col['cap'] ) && ! current_user_can( $col['cap'] ) ) {
895 | continue;
896 | }
897 | if ( isset( $col['connection'] ) && ! function_exists( 'p2p_type' ) ) {
898 | continue;
899 | }
900 |
901 | if ( isset( $col['title_cb'] ) ) {
902 | $new_cols[ $id ] = call_user_func( $col['title_cb'], $col );
903 | } else {
904 | $title = esc_html( $this->get_item_title( $col, $id ) );
905 |
906 | if ( isset( $col['title_icon'] ) ) {
907 | $title = sprintf(
908 | '%s ',
909 | esc_attr( $col['title_icon'] ),
910 | $title
911 | );
912 | }
913 |
914 | $new_cols[ $id ] = $title;
915 | }
916 | }
917 | }
918 |
919 | # Re-add any custom columns:
920 | $custom = array_diff_key( $cols, $this->_cols );
921 | $new_cols = array_merge( $new_cols, $custom );
922 |
923 | $this->the_cols = $new_cols;
924 | return $this->the_cols;
925 | }
926 |
927 | /**
928 | * Output the column data for our custom columns.
929 | *
930 | * @param string $col The column name.
931 | * @param int $post_id The post ID.
932 | */
933 | public function col( string $col, int $post_id ): void {
934 | # Shorthand:
935 | $c = $this->args['admin_cols'];
936 |
937 | # We're only interested in our custom columns:
938 | $custom_cols = array_filter( array_keys( $c ) );
939 |
940 | if ( ! in_array( $col, $custom_cols, true ) ) {
941 | return;
942 | }
943 |
944 | if ( isset( $c[ $col ]['post_cap'] ) && ! current_user_can( $c[ $col ]['post_cap'], get_the_ID() ) ) {
945 | return;
946 | }
947 |
948 | $post = get_post( $post_id );
949 |
950 | if ( ! $post ) {
951 | return;
952 | }
953 |
954 | if ( ! isset( $c[ $col ]['link'] ) ) {
955 | $c[ $col ]['link'] = 'list';
956 | }
957 |
958 | if ( isset( $c[ $col ]['function'] ) ) {
959 | call_user_func( $c[ $col ]['function'], $post );
960 | } elseif ( isset( $c[ $col ]['meta_key'] ) ) {
961 | $this->col_post_meta( $post, $c[ $col ]['meta_key'], $c[ $col ] );
962 | } elseif ( isset( $c[ $col ]['taxonomy'] ) ) {
963 | $this->col_taxonomy( $post, $c[ $col ]['taxonomy'], $c[ $col ] );
964 | } elseif ( isset( $c[ $col ]['post_field'] ) ) {
965 | $this->col_post_field( $post, $c[ $col ]['post_field'], $c[ $col ] );
966 | } elseif ( isset( $c[ $col ]['featured_image'] ) ) {
967 | $this->col_featured_image( $post, $c[ $col ]['featured_image'], $c[ $col ] );
968 | } elseif ( isset( $c[ $col ]['connection'] ) ) {
969 | $this->col_connection( $post, $c[ $col ]['connection'], $c[ $col ] );
970 | }
971 | }
972 |
973 | /**
974 | * Outputs column data for a post meta field.
975 | *
976 | * @param WP_Post $post The post object.
977 | * @param string $meta_key The post meta key.
978 | * @param array $args Array of arguments for this field.
979 | */
980 | public function col_post_meta( WP_Post $post, string $meta_key, array $args ): void {
981 | $vals = get_post_meta( $post->ID, $meta_key, false );
982 | $echo = [];
983 |
984 | sort( $vals );
985 |
986 | if ( isset( $args['date_format'] ) ) {
987 | if ( true === $args['date_format'] ) {
988 | $args['date_format'] = get_option( 'date_format' );
989 | }
990 |
991 | foreach ( $vals as $val ) {
992 | try {
993 | $val_time = ( new DateTime( '@' . $val ) )->format( 'U' );
994 | } catch ( Exception $e ) {
995 | $val_time = strtotime( $val );
996 | }
997 |
998 | if ( false !== $val_time ) {
999 | $val = $val_time;
1000 | }
1001 |
1002 | if ( is_numeric( $val ) ) {
1003 | $echo[] = date_i18n( $args['date_format'], (int) $val );
1004 | } elseif ( ! empty( $val ) ) {
1005 | $echo[] = mysql2date( $args['date_format'], $val );
1006 | }
1007 | }
1008 | } else {
1009 | foreach ( $vals as $val ) {
1010 |
1011 | if ( ! empty( $val ) || ( '0' === $val ) ) {
1012 | $echo[] = $val;
1013 | }
1014 | }
1015 | }
1016 |
1017 | if ( empty( $echo ) ) {
1018 | echo '—';
1019 | } else {
1020 | echo esc_html( implode( ', ', $echo ) );
1021 | }
1022 | }
1023 |
1024 | /**
1025 | * Outputs column data for a taxonomy's term names.
1026 | *
1027 | * @param WP_Post $post The post object.
1028 | * @param string $taxonomy The taxonomy name.
1029 | * @param array $args Array of arguments for this field.
1030 | */
1031 | public function col_taxonomy( WP_Post $post, string $taxonomy, array $args ): void {
1032 | $terms = get_the_terms( $post, $taxonomy );
1033 | $tax = get_taxonomy( $taxonomy );
1034 |
1035 | if ( is_wp_error( $terms ) ) {
1036 | echo esc_html( $terms->get_error_message() );
1037 | return;
1038 | }
1039 |
1040 | if ( ! $tax ) {
1041 | return;
1042 | }
1043 |
1044 | if ( empty( $terms ) ) {
1045 | printf(
1046 | '— %s ',
1047 | esc_html( $tax->labels->no_terms )
1048 | );
1049 | return;
1050 | }
1051 |
1052 | $out = [];
1053 |
1054 | foreach ( $terms as $term ) {
1055 | if ( $args['link'] ) {
1056 | switch ( $args['link'] ) {
1057 |
1058 | case 'view':
1059 | if ( $tax->public ) {
1060 | // https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/issues/1096
1061 | // @codingStandardsIgnoreStart
1062 | $out[] = sprintf(
1063 | '%2$s ',
1064 | esc_url( get_term_link( $term ) ),
1065 | esc_html( $term->name )
1066 | );
1067 | // @codingStandardsIgnoreEnd
1068 | } else {
1069 | $out[] = esc_html( $term->name );
1070 | }
1071 | break;
1072 |
1073 | case 'edit':
1074 | if ( current_user_can( $tax->cap->edit_terms ) ) {
1075 | $out[] = sprintf(
1076 | '%2$s ',
1077 | esc_url( get_edit_term_link( $term->term_id, $taxonomy, $post->post_type ) ),
1078 | esc_html( $term->name )
1079 | );
1080 | } else {
1081 | $out[] = esc_html( $term->name );
1082 | }
1083 | break;
1084 |
1085 | case 'list':
1086 | $link = add_query_arg(
1087 | [
1088 | 'post_type' => $post->post_type,
1089 | $taxonomy => $term->slug,
1090 | ],
1091 | admin_url( 'edit.php' )
1092 | );
1093 | $out[] = sprintf(
1094 | '%2$s ',
1095 | esc_url( $link ),
1096 | esc_html( $term->name )
1097 | );
1098 | break;
1099 |
1100 | }
1101 | } else {
1102 | $out[] = esc_html( $term->name );
1103 | }
1104 | }
1105 |
1106 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1107 | echo implode( ', ', $out );
1108 | }
1109 |
1110 | /**
1111 | * Outputs column data for a post field.
1112 | *
1113 | * @param WP_Post $post The post object.
1114 | * @param string $field The post field.
1115 | * @param array $args Array of arguments for this field.
1116 | */
1117 | public function col_post_field( WP_Post $post, string $field, array $args ): void {
1118 | switch ( $field ) {
1119 |
1120 | case 'post_date':
1121 | case 'post_date_gmt':
1122 | case 'post_modified':
1123 | case 'post_modified_gmt':
1124 | if ( '0000-00-00 00:00:00' !== get_post_field( $field, $post ) ) {
1125 | if ( ! isset( $args['date_format'] ) ) {
1126 | $args['date_format'] = get_option( 'date_format' );
1127 | }
1128 | echo esc_html( mysql2date( $args['date_format'], get_post_field( $field, $post ) ) );
1129 | }
1130 | break;
1131 |
1132 | case 'post_status':
1133 | /** @var \stdClass|null */
1134 | $status = get_post_status_object( $post->post_status );
1135 | if ( $status ) {
1136 | echo esc_html( $status->label );
1137 | }
1138 | break;
1139 |
1140 | case 'post_author':
1141 | $author = get_the_author();
1142 |
1143 | if ( $author ) {
1144 | echo esc_html( $author );
1145 | }
1146 | break;
1147 |
1148 | case 'post_title':
1149 | echo esc_html( get_the_title() );
1150 | break;
1151 |
1152 | case 'post_excerpt':
1153 | echo esc_html( get_the_excerpt() );
1154 | break;
1155 |
1156 | default:
1157 | echo esc_html( get_post_field( $field, $post ) );
1158 | break;
1159 |
1160 | }
1161 | }
1162 |
1163 | /**
1164 | * Outputs column data for a post's featured image.
1165 | *
1166 | * @param WP_Post $post The post object.
1167 | * @param string $image_size The image size.
1168 | * @param array $args Array of `width` and `height` attributes for the image.
1169 | */
1170 | public function col_featured_image( WP_Post $post, string $image_size, array $args ): void {
1171 | if ( ! function_exists( 'has_post_thumbnail' ) ) {
1172 | return;
1173 | }
1174 |
1175 | if ( isset( $args['width'] ) ) {
1176 | $width = is_numeric( $args['width'] ) ? sprintf( '%dpx', $args['width'] ) : $args['width'];
1177 | } else {
1178 | $width = 'auto';
1179 | }
1180 |
1181 | if ( isset( $args['height'] ) ) {
1182 | $height = is_numeric( $args['height'] ) ? sprintf( '%dpx', $args['height'] ) : $args['height'];
1183 | } else {
1184 | $height = 'auto';
1185 | }
1186 |
1187 | $image_atts = [
1188 | 'style' => esc_attr(
1189 | sprintf(
1190 | 'width:%1$s;height:%2$s',
1191 | $width,
1192 | $height
1193 | )
1194 | ),
1195 | 'title' => '',
1196 | ];
1197 |
1198 | if ( has_post_thumbnail() ) {
1199 | the_post_thumbnail( $image_size, $image_atts );
1200 | }
1201 | }
1202 |
1203 | /**
1204 | * Outputs column data for a Posts 2 Posts connection.
1205 | *
1206 | * @param WP_Post $post_object The post object.
1207 | * @param string $connection The ID of the connection type.
1208 | * @param array $args Array of arguments for a given connection type.
1209 | */
1210 | public function col_connection( WP_Post $post_object, string $connection, array $args ): void {
1211 | global $post;
1212 |
1213 | if ( ! function_exists( 'p2p_type' ) ) {
1214 | return;
1215 | }
1216 |
1217 | if ( ! $this->p2p_connection_exists( $connection ) ) {
1218 | echo esc_html(
1219 | sprintf(
1220 | /* translators: %s: The ID of the Posts 2 Posts connection type */
1221 | __( 'Invalid connection type: %s', 'extended-cpts' ),
1222 | $connection
1223 | )
1224 | );
1225 | return;
1226 | }
1227 |
1228 | $_post = $post;
1229 | $meta = [];
1230 | $out = [];
1231 | $field = 'connected_' . $connection;
1232 |
1233 | if ( isset( $args['field'] ) && isset( $args['value'] ) ) {
1234 | $meta = [
1235 | 'connected_meta' => [
1236 | $args['field'] => $args['value'],
1237 | ],
1238 | ];
1239 | $field .= sanitize_title( '_' . $args['field'] . '_' . $args['value'] );
1240 | }
1241 |
1242 | if ( ! isset( $post_object->$field ) ) {
1243 | $type = p2p_type( $connection );
1244 | if ( $type ) {
1245 | $type->each_connected( [ $post_object ], $meta, $field );
1246 | } else {
1247 | echo esc_html(
1248 | sprintf(
1249 | /* translators: %s: The ID of the Posts 2 Posts connection type */
1250 | __( 'Invalid connection type: %s', 'extended-cpts' ),
1251 | $connection
1252 | )
1253 | );
1254 | return;
1255 | }
1256 | }
1257 |
1258 | // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1259 | foreach ( $post_object->$field as $post ) {
1260 | setup_postdata( $post );
1261 |
1262 | /** @var \WP_Post_Type */
1263 | $pto = get_post_type_object( $post->post_type );
1264 | /** @var \stdClass */
1265 | $pso = get_post_status_object( $post->post_status );
1266 |
1267 | if ( $pso->protected && ! current_user_can( 'edit_post', $post->ID ) ) {
1268 | continue;
1269 | }
1270 | if ( 'trash' === $post->post_status ) {
1271 | continue;
1272 | }
1273 |
1274 | if ( $args['link'] ) {
1275 | switch ( $args['link'] ) {
1276 |
1277 | case 'view':
1278 | if ( $pto->public ) {
1279 | if ( $pso->protected ) {
1280 | $out[] = sprintf(
1281 | '%2$s ',
1282 | esc_url( get_preview_post_link() ),
1283 | esc_html( get_the_title() )
1284 | );
1285 | } else {
1286 | $out[] = sprintf(
1287 | '%2$s ',
1288 | esc_url( get_permalink() ),
1289 | esc_html( get_the_title() )
1290 | );
1291 | }
1292 | } else {
1293 | $out[] = esc_html( get_the_title() );
1294 | }
1295 | break;
1296 |
1297 | case 'edit':
1298 | if ( current_user_can( 'edit_post', $post->ID ) ) {
1299 | $out[] = sprintf(
1300 | '%2$s ',
1301 | esc_url( get_edit_post_link() ),
1302 | esc_html( get_the_title() )
1303 | );
1304 | } else {
1305 | $out[] = esc_html( get_the_title() );
1306 | }
1307 | break;
1308 |
1309 | case 'list':
1310 | $link = add_query_arg(
1311 | array_merge(
1312 | [
1313 | 'post_type' => $_post->post_type,
1314 | 'connected_type' => $connection,
1315 | 'connected_items' => $post->ID,
1316 | ],
1317 | $meta
1318 | ),
1319 | admin_url( 'edit.php' )
1320 | );
1321 | $out[] = sprintf(
1322 | '%2$s ',
1323 | esc_url( $link ),
1324 | esc_html( get_the_title() )
1325 | );
1326 | break;
1327 |
1328 | }
1329 | } else {
1330 | $out[] = esc_html( get_the_title() );
1331 | }
1332 | }
1333 |
1334 | // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1335 | $post = $_post;
1336 |
1337 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1338 | echo implode( ', ', $out );
1339 | }
1340 |
1341 | /**
1342 | * Removes the Quick Edit link from the post row actions.
1343 | *
1344 | * @param array $actions Array of post actions.
1345 | * @param WP_Post $post The current post object.
1346 | * @return array Array of updated post actions.
1347 | */
1348 | public function remove_quick_edit_action( array $actions, WP_Post $post ): array {
1349 | if ( $this->cpt->post_type !== $post->post_type ) {
1350 | return $actions;
1351 | }
1352 |
1353 | unset( $actions['inline'], $actions['inline hide-if-no-js'] );
1354 |
1355 | return $actions;
1356 | }
1357 |
1358 | /**
1359 | * Removes the Quick Edit link from the bulk actions menu.
1360 | *
1361 | * @param array $actions Array of bulk actions.
1362 | * @return array Array of updated bulk actions.
1363 | */
1364 | public function remove_quick_edit_menu( array $actions ): array {
1365 | unset( $actions['edit'] );
1366 |
1367 | return $actions;
1368 | }
1369 |
1370 | /**
1371 | * Logs the default columns so we don't remove any custom columns added by other plugins.
1372 | *
1373 | * @param array $cols The default columns for this post type screen.
1374 | * @return array The default columns for this post type screen.
1375 | */
1376 | public function _log_default_cols( array $cols ): array {
1377 | $this->_cols = $cols;
1378 |
1379 | return $this->_cols;
1380 | }
1381 |
1382 | /**
1383 | * A non-localised version of _n()
1384 | *
1385 | * @param string $single The text that will be used if $number is 1.
1386 | * @param string $plural The text that will be used if $number is not 1.
1387 | * @param int $number The number to compare against to use either `$single` or `$plural`.
1388 | * @return string Either `$single` or `$plural` text.
1389 | */
1390 | protected static function n( string $single, string $plural, int $number ): string {
1391 | return ( 1 === intval( $number ) ) ? $single : $plural;
1392 | }
1393 |
1394 | /**
1395 | * Returns a sensible title for the current item (usually the arguments array for a column)
1396 | *
1397 | * @param array $item An array of arguments.
1398 | * @param string $fallback Fallback item title.
1399 | * @return string The item title.
1400 | */
1401 | protected function get_item_title( array $item, string $fallback = '' ): string {
1402 | if ( isset( $item['title'] ) ) {
1403 | return $item['title'];
1404 | } elseif ( isset( $item['taxonomy'] ) ) {
1405 | $tax = get_taxonomy( $item['taxonomy'] );
1406 | if ( $tax ) {
1407 | if ( ! empty( $tax->exclusive ) ) {
1408 | return $tax->labels->singular_name;
1409 | } else {
1410 | return $tax->labels->name;
1411 | }
1412 | } else {
1413 | return $item['taxonomy'];
1414 | }
1415 | } elseif ( isset( $item['post_field'] ) ) {
1416 | return ucwords(
1417 | trim(
1418 | str_replace(
1419 | [
1420 | 'post_',
1421 | '_',
1422 | ],
1423 | ' ',
1424 | $item['post_field']
1425 | )
1426 | )
1427 | );
1428 | } elseif ( isset( $item['meta_key'] ) ) {
1429 | return ucwords(
1430 | trim(
1431 | str_replace(
1432 | [
1433 | '_',
1434 | '-',
1435 | ],
1436 | ' ',
1437 | $item['meta_key']
1438 | )
1439 | )
1440 | );
1441 | } elseif ( isset( $item['connection'] ) && isset( $item['field'] ) && isset( $item['value'] ) ) {
1442 | $fallback = ucwords(
1443 | trim(
1444 | str_replace(
1445 | [
1446 | '_',
1447 | '-',
1448 | ],
1449 | ' ',
1450 | $item['value']
1451 | )
1452 | )
1453 | );
1454 |
1455 | if ( ! function_exists( 'p2p_type' ) || ! $this->p2p_connection_exists( $item['connection'] ) ) {
1456 | return $fallback;
1457 | }
1458 |
1459 | $ctype = p2p_type( $item['connection'] );
1460 | if ( ! $ctype ) {
1461 | return $fallback;
1462 | }
1463 |
1464 | if ( isset( $ctype->fields[ $item['field'] ]['values'][ $item['value'] ] ) ) {
1465 | if ( '' === trim( $ctype->fields[ $item['field'] ]['values'][ $item['value'] ] ) ) {
1466 | return $ctype->fields[ $item['field'] ]['title'];
1467 | } else {
1468 | return $ctype->fields[ $item['field'] ]['values'][ $item['value'] ];
1469 | }
1470 | }
1471 |
1472 | return $fallback;
1473 | } elseif ( isset( $item['connection'] ) ) {
1474 | if ( function_exists( 'p2p_type' ) && $this->p2p_connection_exists( $item['connection'] ) ) {
1475 | $ctype = p2p_type( $item['connection'] );
1476 | if ( $ctype ) {
1477 | $other = ( 'from' === $ctype->direction_from_types( 'post', $this->cpt->post_type ) ) ? 'to' : 'from';
1478 | return $ctype->side[ $other ]->get_title();
1479 | }
1480 | }
1481 | return $item['connection'];
1482 | }
1483 |
1484 | return $fallback;
1485 | }
1486 |
1487 | /**
1488 | * Checks if a certain Posts 2 Posts connection exists.
1489 | *
1490 | * This is just a caching wrapper for `p2p_connection_exists()`, which performs a
1491 | * database query on every call.
1492 | *
1493 | * @param string $connection A connection type.
1494 | * @return bool Whether the connection exists.
1495 | */
1496 | protected function p2p_connection_exists( string $connection ): bool {
1497 | if ( ! isset( $this->connection_exists[ $connection ] ) ) {
1498 | $this->connection_exists[ $connection ] = p2p_connection_exists( $connection );
1499 | }
1500 |
1501 | return $this->connection_exists[ $connection ];
1502 | }
1503 |
1504 | }
1505 |
--------------------------------------------------------------------------------
/src/PostTypeRewriteTesting.php:
--------------------------------------------------------------------------------
1 | cpt = $cpt;
15 | }
16 |
17 | /**
18 | * @return array>
19 | */
20 | public function get_tests(): array {
21 | global $wp_rewrite;
22 |
23 | /** @var \WP_Rewrite $wp_rewrite */
24 |
25 | if ( ! $wp_rewrite->using_permalinks() ) {
26 | return [];
27 | }
28 |
29 | if ( ! isset( $wp_rewrite->extra_permastructs[ $this->cpt->post_type ] ) ) {
30 | return [];
31 | }
32 |
33 | $struct = $wp_rewrite->extra_permastructs[ $this->cpt->post_type ];
34 | /** @var \WP_Post_Type */
35 | $pto = get_post_type_object( $this->cpt->post_type );
36 | $name = sprintf( '%s (%s)', $pto->labels->name, $this->cpt->post_type );
37 | $additional = [];
38 |
39 | // Post type archive rewrites are generated separately. See the `has_archive` handling in `register_post_type()`.
40 | if ( $pto->has_archive && $pto->rewrite ) {
41 | $archive_slug = ( true === $pto->has_archive ) ? $pto->rewrite['slug'] : $pto->has_archive;
42 |
43 | if ( $pto->rewrite['with_front'] ) {
44 | $archive_slug = substr( $wp_rewrite->front, 1 ) . $archive_slug;
45 | } else {
46 | $archive_slug = $wp_rewrite->root . $archive_slug;
47 | }
48 |
49 | $additional[ "{$archive_slug}/?$" ] = "index.php?post_type={$this->cpt->post_type}";
50 |
51 | if ( $pto->rewrite['feeds'] && $wp_rewrite->feeds ) {
52 | $feeds = '(' . trim( implode( '|', $wp_rewrite->feeds ) ) . ')';
53 | $additional[ "{$archive_slug}/feed/{$feeds}/?$" ] = "index.php?post_type={$this->cpt->post_type}" . '&feed=$matches[1]';
54 | $additional[ "{$archive_slug}/{$feeds}/?$" ] = "index.php?post_type={$this->cpt->post_type}" . '&feed=$matches[1]';
55 | }
56 | if ( $pto->rewrite['pages'] ) {
57 | $additional[ "{$archive_slug}/{$wp_rewrite->pagination_base}/([0-9]{1,})/?$" ] = "index.php?post_type={$this->cpt->post_type}" . '&paged=$matches[1]';
58 | }
59 | }
60 |
61 | return [
62 | $name => $this->get_rewrites( $struct, $additional ),
63 | ];
64 | }
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/src/Taxonomy.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | protected array $defaults = [
15 | 'public' => true,
16 | 'show_ui' => true,
17 | 'hierarchical' => true,
18 | 'query_var' => true,
19 | 'exclusive' => false, # Custom arg
20 | 'allow_hierarchy' => false, # Custom arg
21 | ];
22 |
23 | public string $taxonomy;
24 |
25 | /**
26 | * @var array
27 | */
28 | public array $object_type;
29 |
30 | public string $tax_slug;
31 |
32 | public string $tax_singular;
33 |
34 | public string $tax_plural;
35 |
36 | public string $tax_singular_low;
37 |
38 | public string $tax_plural_low;
39 |
40 | /**
41 | * @var array
42 | */
43 | public array $args;
44 |
45 | /**
46 | * Class constructor.
47 | *
48 | * @see register_extended_taxonomy()
49 | *
50 | * @param string $taxonomy The taxonomy name.
51 | * @param array $object_type Names of the object types for the taxonomy.
52 | * @param array $args Optional. The taxonomy arguments.
53 | * @param array $names Optional. An associative array of the plural, singular, and slug names.
54 | * @phpstan-param array{
55 | * plural?: string,
56 | * singular?: string,
57 | * slug?: string,
58 | * } $names
59 | */
60 | public function __construct( string $taxonomy, array $object_type, array $args = [], array $names = [] ) {
61 | /**
62 | * Filter the arguments for a taxonomy.
63 | *
64 | * @since 4.4.1
65 | *
66 | * @param array $args The taxonomy arguments.
67 | * @param string $taxonomy The taxonomy name.
68 | */
69 | $args = apply_filters( 'ext-taxos/args', $args, $taxonomy );
70 |
71 | /**
72 | * Filter the arguments for this taxonomy.
73 | *
74 | * @since 2.0.0
75 | *
76 | * @param array $args The taxonomy arguments.
77 | */
78 | $args = apply_filters( "ext-taxos/{$taxonomy}/args", $args );
79 |
80 | /**
81 | * Filter the plural, singular, and slug for a taxonomy.
82 | *
83 | * @since 4.4.1
84 | *
85 | * @param array $names The plural, singular, and slug names (if any were specified).
86 | * @param string $taxonomy The taxonomy name.
87 | */
88 | $names = apply_filters( 'ext-taxos/names', $names, $taxonomy );
89 |
90 | /**
91 | * Filter the plural, singular, and slug for this taxonomy.
92 | *
93 | * @since 2.0.0
94 | *
95 | * @param array $names The plural, singular, and slug names (if any were specified).
96 | */
97 | $names = apply_filters( "ext-taxos/{$taxonomy}/names", $names );
98 |
99 | if ( isset( $names['singular'] ) ) {
100 | $this->tax_singular = $names['singular'];
101 | } else {
102 | $this->tax_singular = ucwords( str_replace( [ '-', '_' ], ' ', $taxonomy ) );
103 | }
104 |
105 | if ( isset( $names['slug'] ) ) {
106 | $this->tax_slug = $names['slug'];
107 | } elseif ( isset( $names['plural'] ) ) {
108 | $this->tax_slug = $names['plural'];
109 | } else {
110 | $this->tax_slug = $taxonomy . 's';
111 | }
112 |
113 | if ( isset( $names['plural'] ) ) {
114 | $this->tax_plural = $names['plural'];
115 | } else {
116 | $this->tax_plural = ucwords( str_replace( [ '-', '_' ], ' ', $this->tax_slug ) );
117 | }
118 |
119 | $this->object_type = $object_type;
120 | $this->taxonomy = strtolower( $taxonomy );
121 | $this->tax_slug = strtolower( $this->tax_slug );
122 |
123 | # Build our base taxonomy names:
124 | # Lower-casing is not forced if the name looks like an initialism, eg. FAQ.
125 | if ( ! preg_match( '/[A-Z]{2,}/', $this->tax_singular ) ) {
126 | $this->tax_singular_low = strtolower( $this->tax_singular );
127 | } else {
128 | $this->tax_singular_low = $this->tax_singular;
129 | }
130 |
131 | if ( ! preg_match( '/[A-Z]{2,}/', $this->tax_plural ) ) {
132 | $this->tax_plural_low = strtolower( $this->tax_plural );
133 | } else {
134 | $this->tax_plural_low = $this->tax_plural;
135 | }
136 |
137 | # Build our labels:
138 | $this->defaults['labels'] = [
139 | 'menu_name' => $this->tax_plural,
140 | 'name' => $this->tax_plural,
141 | 'singular_name' => $this->tax_singular,
142 | 'name_admin_bar' => $this->tax_singular,
143 | 'search_items' => sprintf( 'Search %s', $this->tax_plural ),
144 | 'popular_items' => sprintf( 'Popular %s', $this->tax_plural ),
145 | 'all_items' => sprintf( 'All %s', $this->tax_plural ),
146 | 'archives' => sprintf( '%s Archives', $this->tax_plural ),
147 | 'parent_item' => sprintf( 'Parent %s', $this->tax_singular ),
148 | 'parent_item_colon' => sprintf( 'Parent %s:', $this->tax_singular ),
149 | 'edit_item' => sprintf( 'Edit %s', $this->tax_singular ),
150 | 'view_item' => sprintf( 'View %s', $this->tax_singular ),
151 | 'update_item' => sprintf( 'Update %s', $this->tax_singular ),
152 | 'add_new_item' => sprintf( 'Add New %s', $this->tax_singular ),
153 | 'new_item_name' => sprintf( 'New %s Name', $this->tax_singular ),
154 | 'separate_items_with_commas' => sprintf( 'Separate %s with commas', $this->tax_plural_low ),
155 | 'add_or_remove_items' => sprintf( 'Add or remove %s', $this->tax_plural_low ),
156 | 'choose_from_most_used' => sprintf( 'Choose from most used %s', $this->tax_plural_low ),
157 | 'not_found' => sprintf( 'No %s found', $this->tax_plural_low ),
158 | 'no_terms' => sprintf( 'No %s', $this->tax_plural_low ),
159 | 'filter_by_item' => sprintf( 'Filter by %s', $this->tax_singular_low ),
160 | 'items_list_navigation' => sprintf( '%s list navigation', $this->tax_plural ),
161 | 'items_list' => sprintf( '%s list', $this->tax_plural ),
162 | 'most_used' => 'Most Used',
163 | 'back_to_items' => sprintf( '← Back to %s', $this->tax_plural ),
164 | 'item_link' => sprintf( '%s Link', $this->tax_singular ),
165 | 'item_link_description' => sprintf( 'A link to a %s.', $this->tax_singular_low ),
166 | 'template_name' => sprintf( '%s Archives', $this->tax_singular ),
167 | 'no_item' => sprintf( 'No %s', $this->tax_singular_low ), # Custom label
168 | 'filter_by' => sprintf( 'Filter by %s', $this->tax_singular_low ), # Custom label
169 | ];
170 |
171 | # Only set rewrites if we need them
172 | if ( isset( $args['public'] ) && ! $args['public'] ) {
173 | $this->defaults['rewrite'] = false;
174 | } else {
175 | $this->defaults['rewrite'] = [
176 | 'slug' => $this->tax_slug,
177 | 'with_front' => false,
178 | 'hierarchical' => isset( $args['allow_hierarchy'] ) ? $args['allow_hierarchy'] : $this->defaults['allow_hierarchy'],
179 | ];
180 | }
181 |
182 | # Merge our args with the defaults:
183 | $this->args = array_merge( $this->defaults, $args );
184 |
185 | # This allows the 'labels' arg to contain some, none or all labels:
186 | if ( isset( $args['labels'] ) ) {
187 | $this->args['labels'] = array_merge( $this->defaults['labels'], $args['labels'] );
188 | }
189 | }
190 |
191 | /**
192 | * Initialise the taxonomy by adding the necessary actions and filters.
193 | */
194 | public function init(): void {
195 | # Rewrite testing:
196 | if ( $this->args['rewrite'] ) {
197 | add_filter( 'rewrite_testing_tests', [ $this, 'rewrite_testing_tests' ], 1 );
198 | }
199 |
200 | # Register taxonomy:
201 | $this->register_taxonomy();
202 |
203 | /**
204 | * Fired when the extended taxonomy instance is set up.
205 | *
206 | * @since 4.0.0
207 | *
208 | * @param \ExtCPTs\Taxonomy $instance The extended taxonomy instance.
209 | */
210 | do_action( "ext-taxos/{$this->taxonomy}/instance", $this );
211 | }
212 |
213 | /**
214 | * Add our rewrite tests to the Rewrite Rule Testing tests array.
215 | *
216 | * @codeCoverageIgnore
217 | *
218 | * @param array> $tests The existing rewrite rule tests.
219 | * @return array> Updated rewrite rule tests.
220 | */
221 | public function rewrite_testing_tests( array $tests ): array {
222 | require_once __DIR__ . '/ExtendedRewriteTesting.php';
223 | require_once __DIR__ . '/TaxonomyRewriteTesting.php';
224 |
225 | $extended = new TaxonomyRewriteTesting( $this );
226 |
227 | return array_merge( $tests, $extended->get_tests() );
228 | }
229 |
230 | /**
231 | * Registers our taxonomy.
232 | */
233 | public function register_taxonomy(): void {
234 | if ( true === $this->args['query_var'] ) {
235 | $query_var = $this->taxonomy;
236 | } else {
237 | $query_var = $this->args['query_var'];
238 | }
239 |
240 | $post_types = get_post_types(
241 | [
242 | 'query_var' => $query_var,
243 | ]
244 | );
245 |
246 | if ( $query_var && count( $post_types ) ) {
247 | trigger_error(
248 | esc_html(
249 | sprintf(
250 | /* translators: %s: Taxonomy query variable name */
251 | __( 'Taxonomy query var "%s" clashes with a post type query var of the same name', 'extended-cpts' ),
252 | $query_var
253 | )
254 | ),
255 | E_USER_ERROR
256 | );
257 | } elseif ( in_array( $query_var, [ 'type', 'tab' ], true ) ) {
258 | trigger_error(
259 | esc_html(
260 | sprintf(
261 | /* translators: %s: Taxonomy query variable name */
262 | __( 'Taxonomy query var "%s" is not allowed', 'extended-cpts' ),
263 | $query_var
264 | )
265 | ),
266 | E_USER_ERROR
267 | );
268 | } else {
269 | register_taxonomy( $this->taxonomy, $this->object_type, $this->args );
270 | }
271 | }
272 |
273 | }
274 |
--------------------------------------------------------------------------------
/src/TaxonomyAdmin.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | protected array $defaults = [
17 | 'meta_box' => null, # Custom arg
18 | 'dashboard_glance' => false, # Custom arg
19 | 'checked_ontop' => null, # Custom arg
20 | 'admin_cols' => null, # Custom arg
21 | 'required' => false, # Custom arg
22 | ];
23 |
24 | public Taxonomy $taxo;
25 |
26 | /**
27 | * @var array
28 | */
29 | public array $args;
30 |
31 | /**
32 | * @var array
33 | */
34 | protected array $_cols;
35 |
36 | /**
37 | * @var array
38 | */
39 | protected ?array $the_cols = null;
40 |
41 | /**
42 | * Class constructor.
43 | *
44 | * @param Taxonomy $taxo An extended taxonomy object.
45 | * @param array $args Optional. The admin arguments.
46 | */
47 | public function __construct( Taxonomy $taxo, array $args = [] ) {
48 | $this->taxo = $taxo;
49 |
50 | # Merge our args with the defaults:
51 | $this->args = array_merge( $this->defaults, $args );
52 |
53 | # Set checked on top to false unless we're using the default meta box:
54 | if ( null === $this->args['checked_ontop'] ) {
55 | $this->args['checked_ontop'] = empty( $this->args['meta_box'] );
56 | }
57 | }
58 |
59 | /**
60 | * Initialise the admin features of the taxonomy by adding the necessary actions and filters.
61 | */
62 | public function init(): void {
63 | # Meta boxes:
64 | if ( $this->taxo->args['exclusive'] || isset( $this->args['meta_box'] ) ) {
65 | add_action( 'add_meta_boxes', [ $this, 'meta_boxes' ], 10, 2 );
66 | }
67 |
68 | # 'At a Glance' dashboard panels:
69 | if ( $this->args['dashboard_glance'] ) {
70 | add_filter( 'dashboard_glance_items', [ $this, 'glance_items' ] );
71 | }
72 |
73 | # Term updated messages:
74 | add_filter( 'term_updated_messages', [ $this, 'term_updated_messages' ], 1 );
75 |
76 | # Admin columns:
77 | if ( $this->args['admin_cols'] ) {
78 | add_filter( "manage_edit-{$this->taxo->taxonomy}_columns", [ $this, '_log_default_cols' ], 0 );
79 | add_filter( "manage_edit-{$this->taxo->taxonomy}_columns", [ $this, 'cols' ] );
80 | add_filter( "manage_{$this->taxo->taxonomy}_custom_column", [ $this, 'col' ], 10, 3 );
81 | }
82 |
83 | /**
84 | * Fired when the extended taxonomy admin instance is set up.
85 | *
86 | * @since 5.0.0
87 | *
88 | * @param \ExtCPTs\TaxonomyAdmin $instance The extended taxonomy admin instance.
89 | */
90 | do_action( "ext-taxos/{$this->taxo->taxonomy}/admin-instance", $this );
91 | }
92 |
93 | /**
94 | * Logs the default columns so we don't remove any custom columns added by other plugins.
95 | *
96 | * @param array $cols The default columns for this taxonomy screen.
97 | * @return array The default columns for this taxonomy screen.
98 | */
99 | public function _log_default_cols( array $cols ): array {
100 | $this->_cols = $cols;
101 | return $this->_cols;
102 | }
103 |
104 | /**
105 | * Add columns to the admin screen for this taxonomy.
106 | *
107 | * Each item in the `admin_cols` array is either a string name of an existing column, or an associative
108 | * array of information for a custom column.
109 | *
110 | * Defining a custom column is easy. Just define an array which includes the column title, column
111 | * type, and optional callback function. You can display columns for term meta or custom functions.
112 | *
113 | * The example below adds two columns; one which displays the value of the term's `term_updated` meta
114 | * key, and one which calls a custom callback function:
115 | *
116 | * register_extended_taxonomy( 'foo', 'bar', array(
117 | * 'admin_cols' => array(
118 | * 'foo_updated' => array(
119 | * 'title' => 'Updated',
120 | * 'meta_key' => 'term_updated'
121 | * ),
122 | * 'foo_bar' => array(
123 | * 'title' => 'Example',
124 | * 'function' => 'my_custom_callback'
125 | * )
126 | * )
127 | * ) );
128 | *
129 | * That's all you need to do. The columns will handle safely outputting the data
130 | * (escaping text, and comma-separating taxonomy terms). No more messing about with all of those
131 | * annoyingly named column filters and actions.
132 | *
133 | * Each item in the `admin_cols` array must contain one of the following elements which defines the column type:
134 | *
135 | * - meta_key - A term meta key
136 | * - function - The name of a callback function
137 | *
138 | * The value for the corresponding term meta are safely escaped and output into the column.
139 | *
140 | * There are a few optional elements:
141 | *
142 | * - title - Generated from the field if not specified.
143 | * - function - The name of a callback function for the column (eg. `my_function`) which gets called
144 | * instead of the built-in function for handling that column. The function is passed the term ID as
145 | * its first parameter.
146 | * - date_format - This is used with the `meta_key` column type. The value of the meta field will be
147 | * treated as a timestamp if this is present. Unix and MySQL format timestamps are supported in the
148 | * meta value. Pass in boolean true to format the date according to the 'Date Format' setting, or pass
149 | * in a valid date formatting string (eg. `d/m/Y H:i:s`).
150 | * - cap - A capability required in order for this column to be displayed to the current user. Defaults
151 | * to null, meaning the column is shown to all users.
152 | *
153 | * Note that sortable admin columns are not yet supported.
154 | *
155 | * @param array $cols Associative array of columns.
156 | * @return array Updated array of columns.
157 | */
158 | public function cols( array $cols ): array {
159 | // This function gets called multiple times, so let's cache it for efficiency:
160 | if ( isset( $this->the_cols ) ) {
161 | return $this->the_cols;
162 | }
163 |
164 | $new_cols = [];
165 | $keep = [
166 | 'cb',
167 | 'name',
168 | 'description',
169 | 'slug',
170 | ];
171 |
172 | # Add existing columns we want to keep:
173 | foreach ( $cols as $id => $title ) {
174 | if ( in_array( $id, $keep, true ) && ! isset( $this->args['admin_cols'][ $id ] ) ) {
175 | $new_cols[ $id ] = $title;
176 | }
177 | }
178 |
179 | /** @var array */
180 | $admin_cols = array_filter( $this->args['admin_cols'] );
181 |
182 | # Add our custom columns:
183 | foreach ( $admin_cols as $id => $col ) {
184 | if ( is_string( $col ) && isset( $cols[ $col ] ) ) {
185 | # Existing (ie. built-in) column with id as the value
186 | $new_cols[ $col ] = $cols[ $col ];
187 | } elseif ( is_string( $col ) && isset( $cols[ $id ] ) ) {
188 | # Existing (ie. built-in) column with id as the key and title as the value
189 | $new_cols[ $id ] = esc_html( $col );
190 | } elseif ( is_array( $col ) ) {
191 | if ( isset( $col['cap'] ) && ! current_user_can( $col['cap'] ) ) {
192 | continue;
193 | }
194 |
195 | if ( isset( $col['title_cb'] ) ) {
196 | $new_cols[ $id ] = call_user_func( $col['title_cb'], $col );
197 | } else {
198 | $title = esc_html( $this->get_item_title( $col, $id ) );
199 |
200 | if ( isset( $col['title_icon'] ) ) {
201 | $title = sprintf(
202 | '%s ',
203 | esc_attr( $col['title_icon'] ),
204 | $title
205 | );
206 | }
207 |
208 | $new_cols[ $id ] = $title;
209 | }
210 | }
211 | }
212 |
213 | # Re-add any custom columns:
214 | $custom = array_diff_key( $cols, $this->_cols );
215 | $new_cols = array_merge( $new_cols, $custom );
216 |
217 | $this->the_cols = $new_cols;
218 | return $this->the_cols;
219 | }
220 |
221 | /**
222 | * Output the column data for our custom columns.
223 | *
224 | * @param string $string Blank string.
225 | * @param string $col Name of the column.
226 | * @param int $term_id Term ID.
227 | * @return string Blank string.
228 | */
229 | public function col( string $string, string $col, int $term_id ): string {
230 | # Shorthand:
231 | $c = $this->args['admin_cols'];
232 |
233 | # We're only interested in our custom columns:
234 | $custom_cols = array_filter( array_keys( $c ) );
235 |
236 | if ( ! in_array( $col, $custom_cols, true ) ) {
237 | return $string;
238 | }
239 |
240 | if ( isset( $c[ $col ]['function'] ) ) {
241 | call_user_func( $c[ $col ]['function'], $term_id );
242 | } elseif ( isset( $c[ $col ]['meta_key'] ) ) {
243 | $this->col_term_meta( $c[ $col ]['meta_key'], $c[ $col ], $term_id );
244 | }
245 |
246 | return $string;
247 | }
248 |
249 | /**
250 | * Output column data for a term meta field.
251 | *
252 | * @param string $meta_key The term meta key.
253 | * @param array $args Array of arguments for this field.
254 | * @param int $term_id Term ID.
255 | */
256 | public function col_term_meta( string $meta_key, array $args, int $term_id ): void {
257 | $vals = get_term_meta( $term_id, $meta_key, false );
258 | $echo = [];
259 |
260 | sort( $vals );
261 |
262 | if ( isset( $args['date_format'] ) ) {
263 | if ( true === $args['date_format'] ) {
264 | $args['date_format'] = get_option( 'date_format' );
265 | }
266 |
267 | foreach ( $vals as $val ) {
268 | if ( is_numeric( $val ) ) {
269 | $echo[] = date( $args['date_format'], (int) $val );
270 | } elseif ( ! empty( $val ) ) {
271 | $echo[] = mysql2date( $args['date_format'], $val );
272 | }
273 | }
274 | } else {
275 | foreach ( $vals as $val ) {
276 | if ( ! empty( $val ) || ( '0' === $val ) ) {
277 | $echo[] = $val;
278 | }
279 | }
280 | }
281 |
282 | if ( empty( $echo ) ) {
283 | echo '—';
284 | } else {
285 | echo esc_html( implode( ', ', $echo ) );
286 | }
287 | }
288 |
289 | /**
290 | * Returns a sensible title for the current item (usually the arguments array for a column).
291 | *
292 | * @param array $item An array of arguments.
293 | * @param string $fallback Fallback item title.
294 | * @return string The item title.
295 | */
296 | protected function get_item_title( array $item, string $fallback = '' ): string {
297 | if ( isset( $item['title'] ) ) {
298 | return $item['title'];
299 | } elseif ( isset( $item['meta_key'] ) ) {
300 | return ucwords( trim( str_replace( [ '_', '-' ], ' ', $item['meta_key'] ) ) );
301 | }
302 |
303 | return $fallback;
304 | }
305 |
306 | /**
307 | * Removes the default meta box from the post editing screen and adds our custom meta box.
308 | *
309 | * @param string $object_type The object type (eg. the post type).
310 | * @param mixed $object The object (eg. a WP_Post object).
311 | */
312 | public function meta_boxes( string $object_type, $object ): void {
313 | if ( ! is_a( $object, 'WP_Post' ) ) {
314 | return;
315 | }
316 |
317 | $post_type = $object_type;
318 | $post = $object;
319 | $taxos = get_post_taxonomies( $post );
320 |
321 | if ( in_array( $this->taxo->taxonomy, $taxos, true ) ) {
322 | /** @var WP_Taxonomy */
323 | $tax = get_taxonomy( $this->taxo->taxonomy );
324 |
325 | # Remove default meta box from classic editor:
326 | if ( $this->taxo->args['hierarchical'] ) {
327 | remove_meta_box( "{$this->taxo->taxonomy}div", $post_type, 'side' );
328 | } else {
329 | remove_meta_box( "tagsdiv-{$this->taxo->taxonomy}", $post_type, 'side' );
330 | }
331 |
332 | $store = version_compare( $GLOBALS['wp_version'], '6.5', '>=' ) ? 'core/editor' : 'core/edit-post';
333 |
334 | # Remove default meta box from block editor:
335 | wp_add_inline_script(
336 | 'wp-edit-post',
337 | sprintf(
338 | 'wp.data.dispatch( "%s" ).removeEditorPanel( "taxonomy-panel-%s" );',
339 | $store,
340 | $this->taxo->taxonomy
341 | )
342 | );
343 |
344 | if ( ! current_user_can( $tax->cap->assign_terms ) ) {
345 | return;
346 | }
347 |
348 | if ( $this->args['meta_box'] ) {
349 | # Set the 'meta_box' argument to the actual meta box callback function name:
350 | if ( 'simple' === $this->args['meta_box'] ) {
351 | if ( $this->taxo->args['exclusive'] ) {
352 | $this->args['meta_box'] = [ $this, 'meta_box_radio' ];
353 | } else {
354 | $this->args['meta_box'] = [ $this, 'meta_box_simple' ];
355 | }
356 | } elseif ( 'radio' === $this->args['meta_box'] ) {
357 | $this->taxo->args['exclusive'] = true;
358 | $this->args['meta_box'] = [ $this, 'meta_box_radio' ];
359 | } elseif ( 'dropdown' === $this->args['meta_box'] ) {
360 | $this->taxo->args['exclusive'] = true;
361 | $this->args['meta_box'] = [ $this, 'meta_box_dropdown' ];
362 | }
363 |
364 | # Add the meta box, using the plural or singular taxonomy label where relevant:
365 | if ( $this->taxo->args['exclusive'] ) {
366 | add_meta_box( "{$this->taxo->taxonomy}div", $tax->labels->singular_name, $this->args['meta_box'], $post_type, 'side' );
367 | } else {
368 | add_meta_box( "{$this->taxo->taxonomy}div", $tax->labels->name, $this->args['meta_box'], $post_type, 'side' );
369 | }
370 | } elseif ( false !== $this->args['meta_box'] ) {
371 | # This must be an 'exclusive' taxonomy. Add the radio meta box:
372 | add_meta_box( "{$this->taxo->taxonomy}div", $tax->labels->singular_name, [ $this, 'meta_box_radio' ], $post_type, 'side' );
373 | }
374 | }
375 | }
376 |
377 | /**
378 | * Displays the 'radio' meta box on the post editing screen.
379 | *
380 | * Uses the Walker\Radios class for the walker.
381 | *
382 | * @param WP_Post $post The post object.
383 | * @param array $meta_box The meta box arguments.
384 | */
385 | public function meta_box_radio( WP_Post $post, array $meta_box ): void {
386 | $walker = new Walker\Radios();
387 | $this->do_meta_box( $post, $walker, true, 'checklist' );
388 | }
389 |
390 | /**
391 | * Displays the 'dropdown' meta box on the post editing screen.
392 | *
393 | * Uses the Walker\Dropdown class for the walker.
394 | *
395 | * @param WP_Post $post The post object.
396 | * @param array $meta_box The meta box arguments.
397 | */
398 | public function meta_box_dropdown( WP_Post $post, array $meta_box ): void {
399 | $walker = new Walker\Dropdown();
400 | $this->do_meta_box( $post, $walker, true, 'dropdown' );
401 | }
402 |
403 | /**
404 | * Displays the 'simple' meta box on the post editing screen.
405 | *
406 | * @param WP_Post $post The post object.
407 | * @param array $meta_box The meta box arguments.
408 | */
409 | public function meta_box_simple( WP_Post $post, array $meta_box ): void {
410 | $this->do_meta_box( $post );
411 | }
412 |
413 | /**
414 | * Displays a meta box on the post editing screen.
415 | *
416 | * @param WP_Post $post The post object.
417 | * @param \Walker $walker Optional. A term walker.
418 | * @param bool $show_none Optional. Whether to include a 'none' item in the term list. Default false.
419 | * @param string $type Optional. The taxonomy list type (checklist or dropdown). Default 'checklist'.
420 | */
421 | protected function do_meta_box( WP_Post $post, ?\Walker $walker = null, bool $show_none = false, string $type = 'checklist' ): void {
422 | $taxonomy = $this->taxo->taxonomy;
423 | /** @var WP_Taxonomy */
424 | $tax = get_taxonomy( $taxonomy );
425 | /** @var array */
426 | $selected = wp_get_object_terms(
427 | $post->ID,
428 | $taxonomy,
429 | [
430 | 'fields' => 'ids',
431 | ]
432 | );
433 |
434 | if ( $show_none ) {
435 | if ( isset( $tax->labels->no_item ) ) {
436 | /** @var string $none */
437 | $none = $tax->labels->no_item;
438 | } else {
439 | $none = esc_html__( 'Not specified', 'extended-cpts' );
440 | }
441 | } else {
442 | $none = '';
443 | }
444 |
445 | /**
446 | * Execute code before the taxonomy meta box content outputs to the page.
447 | *
448 | * @since 2.0.0
449 | *
450 | * @param WP_Taxonomy $tax The current taxonomy object.
451 | * @param WP_Post $post The current post object.
452 | * @param string $type The taxonomy list type ('checklist' or 'dropdown').
453 | */
454 | do_action( 'ext-taxos/meta_box/before', $tax, $post, $type );
455 |
456 | ?>
457 |
458 |
459 | %2$s',
466 | esc_attr( "{$taxonomy}dropdown" ),
467 | esc_html( $tax->labels->singular_name )
468 | );
469 |
470 | $dropdown_args = [
471 | 'option_none_value' => ( is_taxonomy_hierarchical( $taxonomy ) ? '-1' : '' ),
472 | 'show_option_none' => $none,
473 | 'hide_empty' => false,
474 | 'hierarchical' => true,
475 | 'show_count' => false,
476 | 'orderby' => 'name',
477 | 'selected' => reset( $selected ) ?: 0,
478 | 'id' => "{$taxonomy}dropdown",
479 | 'name' => is_taxonomy_hierarchical( $taxonomy ) ? "tax_input[{$taxonomy}][]" : "tax_input[{$taxonomy}]",
480 | 'taxonomy' => $taxonomy,
481 | 'required' => $this->args['required'],
482 | ];
483 |
484 | if ( $walker instanceof \Walker ) {
485 | $dropdown_args['walker'] = $walker;
486 | }
487 |
488 | wp_dropdown_categories( $dropdown_args );
489 | break;
490 |
491 | case 'checklist':
492 | default:
493 | ?>
494 |
503 |
504 |
505 |
506 |
553 |
554 |
560 |
561 |
562 | $items Array of items to display on the widget.
580 | * @return array Updated array of items.
581 | */
582 | public function glance_items( array $items ): array {
583 | /** @var WP_Taxonomy */
584 | $taxonomy = get_taxonomy( $this->taxo->taxonomy );
585 |
586 | if ( ! current_user_can( $taxonomy->cap->manage_terms ) ) {
587 | return $items;
588 | }
589 | if ( $taxonomy->_builtin ) {
590 | return $items;
591 | }
592 |
593 | # Get the labels and format the counts:
594 | $count = wp_count_terms(
595 | [
596 | 'taxonomy' => $this->taxo->taxonomy,
597 | ]
598 | );
599 |
600 | if ( is_wp_error( $count ) ) {
601 | return $items;
602 | }
603 |
604 | $text = self::n( $taxonomy->labels->singular_name, $taxonomy->labels->name, (int) $count );
605 | $num = number_format_i18n( (int) $count );
606 |
607 | # This is absolutely not localisable. WordPress 3.8 didn't add a new taxonomy label.
608 | $url = add_query_arg(
609 | [
610 | 'taxonomy' => $this->taxo->taxonomy,
611 | 'post_type' => reset( $taxonomy->object_type ),
612 | ],
613 | admin_url( 'edit-tags.php' )
614 | );
615 | $text = '' . esc_html( $num . ' ' . $text ) . ' ';
616 |
617 | # Go!
618 | $items[] = $text;
619 |
620 | return $items;
621 | }
622 |
623 | /**
624 | * Adds our term updated messages.
625 | *
626 | * The messages are as follows:
627 | *
628 | * 1 => "Term added."
629 | * 2 => "Term deleted."
630 | * 3 => "Term updated."
631 | * 4 => "Term not added."
632 | * 5 => "Term not updated."
633 | * 6 => "Terms deleted."
634 | *
635 | * @param array> $messages An array of term updated message arrays keyed by taxonomy name.
636 | * @return array> Updated array of term updated messages.
637 | */
638 | public function term_updated_messages( array $messages ): array {
639 | $messages[ $this->taxo->taxonomy ] = [
640 | 1 => esc_html( sprintf( '%s added.', $this->taxo->tax_singular ) ),
641 | 2 => esc_html( sprintf( '%s deleted.', $this->taxo->tax_singular ) ),
642 | 3 => esc_html( sprintf( '%s updated.', $this->taxo->tax_singular ) ),
643 | 4 => esc_html( sprintf( '%s not added.', $this->taxo->tax_singular ) ),
644 | 5 => esc_html( sprintf( '%s not updated.', $this->taxo->tax_singular ) ),
645 | 6 => esc_html( sprintf( '%s deleted.', $this->taxo->tax_plural ) ),
646 | ];
647 |
648 | return $messages;
649 | }
650 |
651 | /**
652 | * A non-localised version of _n()
653 | *
654 | * @param string $single The text that will be used if $number is 1.
655 | * @param string $plural The text that will be used if $number is not 1.
656 | * @param int $number The number to compare against to use either $single or $plural.
657 | * @return string Either $single or $plural text.
658 | */
659 | public static function n( string $single, string $plural, int $number ): string {
660 | return ( 1 === intval( $number ) ) ? $single : $plural;
661 | }
662 |
663 | }
664 |
--------------------------------------------------------------------------------
/src/TaxonomyRewriteTesting.php:
--------------------------------------------------------------------------------
1 | taxo = $taxo;
14 | }
15 |
16 | /**
17 | * @return array>
18 | */
19 | public function get_tests(): array {
20 | global $wp_rewrite;
21 |
22 | if ( ! $wp_rewrite->using_permalinks() ) {
23 | return [];
24 | }
25 |
26 | if ( ! isset( $wp_rewrite->extra_permastructs[ $this->taxo->taxonomy ] ) ) {
27 | return [];
28 | }
29 |
30 | $struct = $wp_rewrite->extra_permastructs[ $this->taxo->taxonomy ];
31 | /** @var WP_Taxonomy */
32 | $tax = get_taxonomy( $this->taxo->taxonomy );
33 | $name = sprintf( '%s (%s)', $tax->labels->name, $this->taxo->taxonomy );
34 |
35 | return [
36 | $name => $this->get_rewrites( $struct, [] ),
37 | ];
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/src/Walker/Checkboxes.php:
--------------------------------------------------------------------------------
1 | elements properly.
10 | */
11 | class Checkboxes extends \Walker {
12 |
13 | /**
14 | * @var string
15 | */
16 | public $tree_type = 'category';
17 |
18 | /**
19 | * @var array
20 | */
21 | public $db_fields = [
22 | 'parent' => 'parent',
23 | 'id' => 'term_id',
24 | ];
25 |
26 | /**
27 | * @var string
28 | */
29 | public $field = null;
30 |
31 | /**
32 | * Class constructor.
33 | *
34 | * @param array $args Optional arguments.
35 | */
36 | public function __construct( $args = null ) {
37 | if ( $args && isset( $args['field'] ) ) {
38 | $this->field = $args['field'];
39 | }
40 | }
41 |
42 | /**
43 | * Starts the list before the elements are added.
44 | *
45 | * @param string $output Passed by reference. Used to append additional content.
46 | * @param int $depth Depth of term in reference to parents.
47 | * @param array $args Optional arguments.
48 | * @return void
49 | */
50 | public function start_lvl( &$output, $depth = 0, $args = [] ) {
51 | $indent = str_repeat( "\t", $depth );
52 | $output .= "$indent\n";
53 | }
54 |
55 | /**
56 | * Ends the list of after the elements are added.
57 | *
58 | * @param string $output Passed by reference. Used to append additional content.
59 | * @param int $depth Depth of term in reference to parents.
60 | * @param array $args Optional arguments.
61 | * @return void
62 | */
63 | public function end_lvl( &$output, $depth = 0, $args = [] ) {
64 | $indent = str_repeat( "\t", $depth );
65 | $output .= "$indent \n";
66 | }
67 |
68 | /**
69 | * Start the element output.
70 | *
71 | * @param string $output Passed by reference. Used to append additional content.
72 | * @param WP_Term $object Term data object.
73 | * @param int $depth Depth of term in reference to parents.
74 | * @param array $args Optional arguments.
75 | * @param int $current_object_id Current object ID.
76 | * @return void
77 | */
78 | public function start_el( &$output, $object, $depth = 0, $args = [], $current_object_id = 0 ) {
79 | $tax = get_taxonomy( $args['taxonomy'] );
80 |
81 | if ( ! $tax ) {
82 | return;
83 | }
84 |
85 | if ( $this->field ) {
86 | $value = $object->{$this->field};
87 | } else {
88 | $value = $tax->hierarchical ? $object->term_id : $object->name;
89 | }
90 |
91 | if ( empty( $object->term_id ) && ! $tax->hierarchical ) {
92 | $value = '';
93 | }
94 |
95 | $output .= "\nterm_id}'>" .
96 | '' .
97 | ' term_id ) . '"' .
99 | checked( in_array( $object->term_id, (array) $args['selected_cats'] ), true, false ) .
100 | disabled( empty( $args['disabled'] ), false, false ) .
101 | ' /> ' .
102 | esc_html( apply_filters( 'the_category', $object->name ) ) .
103 | ' ';
104 | }
105 |
106 | /**
107 | * Ends the element output, if needed.
108 | *
109 | * @param string $output Passed by reference. Used to append additional content.
110 | * @param WP_Term $object Term data object.
111 | * @param int $depth Depth of term in reference to parents.
112 | * @param array $args Optional arguments.
113 | * @return void
114 | */
115 | public function end_el( &$output, $object, $depth = 0, $args = [] ) {
116 | $output .= " \n";
117 | }
118 |
119 | }
120 |
--------------------------------------------------------------------------------
/src/Walker/Dropdown.php:
--------------------------------------------------------------------------------
1 |
20 | */
21 | public $db_fields = [
22 | 'parent' => 'parent',
23 | 'id' => 'term_id',
24 | ];
25 |
26 | /**
27 | * @var string
28 | */
29 | public $field = null;
30 |
31 | /**
32 | * Class constructor.
33 | *
34 | * @param array $args Optional arguments.
35 | */
36 | public function __construct( $args = null ) {
37 | if ( $args && isset( $args['field'] ) ) {
38 | $this->field = $args['field'];
39 | }
40 | }
41 |
42 | /**
43 | * Start the element output.
44 | *
45 | * @param string $output Passed by reference. Used to append additional content.
46 | * @param WP_Term $object Term data object.
47 | * @param int $depth Depth of term in reference to parents.
48 | * @param array $args Optional arguments.
49 | * @param int $current_object_id Current object ID.
50 | * @return void
51 | */
52 | public function start_el( &$output, $object, $depth = 0, $args = [], $current_object_id = 0 ) {
53 | $pad = str_repeat( ' ', $depth * 3 );
54 | $tax = get_taxonomy( $args['taxonomy'] );
55 |
56 | if ( ! $tax ) {
57 | return;
58 | }
59 |
60 | if ( $this->field ) {
61 | $value = $object->{$this->field};
62 | } else {
63 | $value = $tax->hierarchical ? $object->term_id : $object->name;
64 | }
65 |
66 | if ( empty( $object->term_id ) && ! $tax->hierarchical ) {
67 | $value = '';
68 | }
69 |
70 | $cat_name = apply_filters( 'list_cats', $object->name, $object );
71 | $output .= "\tterm_id, (array) $args['selected'] ) ) {
76 | $output .= ' selected="selected"';
77 | }
78 |
79 | $output .= '>';
80 | $output .= $pad . esc_html( $cat_name );
81 |
82 | if ( $args['show_count'] ) {
83 | $output .= ' (' . esc_html( number_format_i18n( $object->count ) ) . ')';
84 | }
85 |
86 | $output .= " \n";
87 | }
88 |
89 | }
90 |
--------------------------------------------------------------------------------
/src/Walker/Radios.php:
--------------------------------------------------------------------------------
1 |
20 | */
21 | public $db_fields = [
22 | 'parent' => 'parent',
23 | 'id' => 'term_id',
24 | ];
25 |
26 | /**
27 | * @var string
28 | */
29 | public $field = null;
30 |
31 | /**
32 | * Class constructor.
33 | *
34 | * @param array $args Optional arguments.
35 | */
36 | public function __construct( $args = null ) {
37 | if ( $args && isset( $args['field'] ) ) {
38 | $this->field = $args['field'];
39 | }
40 | }
41 |
42 | /**
43 | * Starts the list before the elements are added.
44 | *
45 | * @param string $output Passed by reference. Used to append additional content.
46 | * @param int $depth Depth of term in reference to parents.
47 | * @param array $args Optional arguments.
48 | * @return void
49 | */
50 | public function start_lvl( &$output, $depth = 0, $args = [] ) {
51 | $indent = str_repeat( "\t", $depth );
52 | $output .= "{$indent}\n";
53 | }
54 |
55 | /**
56 | * Ends the list of after the elements are added.
57 | *
58 | * @param string $output Passed by reference. Used to append additional content.
59 | * @param int $depth Depth of term in reference to parents.
60 | * @param array $args Optional arguments.
61 | * @return void
62 | */
63 | public function end_lvl( &$output, $depth = 0, $args = [] ) {
64 | $indent = str_repeat( "\t", $depth );
65 | $output .= "{$indent} \n";
66 | }
67 |
68 | /**
69 | * Start the element output.
70 | *
71 | * @param string $output Passed by reference. Used to append additional content.
72 | * @param WP_Term $object Term data object.
73 | * @param int $depth Depth of term in reference to parents.
74 | * @param array $args Optional arguments.
75 | * @param int $current_object_id Current object ID.
76 | * @return void
77 | */
78 | public function start_el( &$output, $object, $depth = 0, $args = [], $current_object_id = 0 ) {
79 | $tax = get_taxonomy( $args['taxonomy'] );
80 |
81 | if ( ! $tax ) {
82 | return;
83 | }
84 |
85 | if ( $this->field ) {
86 | $value = $object->{$this->field};
87 | } else {
88 | $value = $tax->hierarchical ? $object->term_id : $object->name;
89 | }
90 |
91 | if ( empty( $object->term_id ) && ! $tax->hierarchical ) {
92 | $value = '';
93 | }
94 |
95 | $output .= "\nterm_id}'>" .
96 | '' .
97 | ' term_id ) . '"' .
99 | checked( in_array( $object->term_id, (array) $args['selected_cats'] ), true, false ) .
100 | disabled( empty( $args['disabled'] ), false, false ) .
101 | ' /> ' .
102 | esc_html( apply_filters( 'the_category', $object->name ) ) .
103 | ' ';
104 | }
105 |
106 | /**
107 | * Ends the element output, if needed.
108 | *
109 | * @param string $output Passed by reference. Used to append additional content.
110 | * @param WP_Term $object Term data object.
111 | * @param int $depth Depth of term in reference to parents.
112 | * @param array $args Optional arguments.
113 | * @return void
114 | */
115 | public function end_el( &$output, $object, $depth = 0, $args = [] ) {
116 | $output .= " \n";
117 | }
118 |
119 | }
120 |
--------------------------------------------------------------------------------
/src/dashicons-codepoints.json:
--------------------------------------------------------------------------------
1 | {
2 | "menu": 62259,
3 | "admin-site": 62233,
4 | "dashboard": 61990,
5 | "admin-media": 61700,
6 | "admin-page": 61701,
7 | "admin-comments": 61697,
8 | "admin-appearance": 61696,
9 | "admin-plugins": 61702,
10 | "admin-users": 61712,
11 | "admin-tools": 61703,
12 | "admin-settings": 61704,
13 | "admin-network": 61714,
14 | "admin-generic": 61713,
15 | "admin-home": 61698,
16 | "admin-collapse": 61768,
17 | "filter": 62774,
18 | "admin-customizer": 62784,
19 | "admin-multisite": 62785,
20 | "admin-links": 61699,
21 | "admin-post": 61705,
22 | "format-image": 61736,
23 | "format-gallery": 61793,
24 | "format-audio": 61735,
25 | "format-video": 61734,
26 | "format-chat": 61733,
27 | "format-status": 61744,
28 | "format-aside": 61731,
29 | "format-quote": 61730,
30 | "welcome-write-blog": 61721,
31 | "welcome-add-page": 61747,
32 | "welcome-view-site": 61717,
33 | "welcome-widgets-menus": 61718,
34 | "welcome-comments": 61719,
35 | "welcome-learn-more": 61720,
36 | "image-crop": 61797,
37 | "image-rotate": 62769,
38 | "image-rotate-left": 61798,
39 | "image-rotate-right": 61799,
40 | "image-flip-vertical": 61800,
41 | "image-flip-horizontal": 61801,
42 | "image-filter": 62771,
43 | "undo": 61809,
44 | "redo": 61810,
45 | "editor-bold": 61952,
46 | "editor-italic": 61953,
47 | "editor-ul": 61955,
48 | "editor-ol": 61956,
49 | "editor-quote": 61957,
50 | "editor-alignleft": 61958,
51 | "editor-aligncenter": 61959,
52 | "editor-alignright": 61960,
53 | "editor-insertmore": 61961,
54 | "editor-spellcheck": 61968,
55 | "editor-expand": 61969,
56 | "editor-contract": 62726,
57 | "editor-kitchensink": 61970,
58 | "editor-underline": 61971,
59 | "editor-justify": 61972,
60 | "editor-textcolor": 61973,
61 | "editor-paste-word": 61974,
62 | "editor-paste-text": 61975,
63 | "editor-removeformatting": 61976,
64 | "editor-video": 61977,
65 | "editor-customchar": 61984,
66 | "editor-outdent": 61985,
67 | "editor-indent": 61986,
68 | "editor-help": 61987,
69 | "editor-strikethrough": 61988,
70 | "editor-unlink": 61989,
71 | "editor-rtl": 62240,
72 | "editor-break": 62580,
73 | "editor-code": 62581,
74 | "editor-code-duplicate": 62612,
75 | "editor-paragraph": 62582,
76 | "editor-table": 62773,
77 | "align-left": 61749,
78 | "align-right": 61750,
79 | "align-center": 61748,
80 | "align-none": 61752,
81 | "lock": 61792,
82 | "lock-duplicate": 62229,
83 | "unlock": 62760,
84 | "calendar": 61765,
85 | "calendar-alt": 62728,
86 | "visibility": 61815,
87 | "hidden": 62768,
88 | "post-status": 61811,
89 | "edit": 62564,
90 | "edit-large": 62247,
91 | "sticky": 62775,
92 | "external": 62724,
93 | "arrow-up": 61762,
94 | "arrow-up-duplicate": 61763,
95 | "arrow-down": 61760,
96 | "arrow-left": 61761,
97 | "arrow-right": 61753,
98 | "arrow-up-alt": 62274,
99 | "arrow-down-alt": 62278,
100 | "arrow-left-alt": 62272,
101 | "arrow-right-alt": 62276,
102 | "arrow-up-alt2": 62275,
103 | "arrow-down-alt2": 62279,
104 | "arrow-left-alt2": 62273,
105 | "arrow-right-alt2": 62277,
106 | "leftright": 61993,
107 | "sort": 61782,
108 | "randomize": 62723,
109 | "list-view": 61795,
110 | "excerpt-view": 61796,
111 | "grid-view": 62729,
112 | "move": 62789,
113 | "hammer": 62216,
114 | "art": 62217,
115 | "migrate": 62224,
116 | "performance": 62225,
117 | "universal-access": 62595,
118 | "universal-access-alt": 62727,
119 | "tickets": 62598,
120 | "nametag": 62596,
121 | "clipboard": 62593,
122 | "heart": 62599,
123 | "megaphone": 62600,
124 | "schedule": 62601,
125 | "wordpress": 61728,
126 | "wordpress-alt": 62244,
127 | "pressthis": 61783,
128 | "update": 62563,
129 | "screenoptions": 61824,
130 | "cart": 61812,
131 | "feedback": 61813,
132 | "translation": 62246,
133 | "tag": 62243,
134 | "category": 62232,
135 | "archive": 62592,
136 | "tagcloud": 62585,
137 | "text": 62584,
138 | "media-archive": 62721,
139 | "media-audio": 62720,
140 | "media-code": 62617,
141 | "media-default": 62616,
142 | "media-document": 62615,
143 | "media-interactive": 62614,
144 | "media-spreadsheet": 62613,
145 | "media-text": 62609,
146 | "media-video": 62608,
147 | "playlist-audio": 62610,
148 | "playlist-video": 62611,
149 | "controls-play": 62754,
150 | "controls-pause": 62755,
151 | "controls-forward": 62745,
152 | "controls-skipforward": 62743,
153 | "controls-back": 62744,
154 | "controls-skipback": 62742,
155 | "controls-repeat": 62741,
156 | "controls-volumeon": 62753,
157 | "controls-volumeoff": 62752,
158 | "yes": 61767,
159 | "no": 61784,
160 | "no-alt": 62261,
161 | "plus": 61746,
162 | "plus-alt": 62722,
163 | "plus-alt2": 62787,
164 | "minus": 62560,
165 | "dismiss": 61779,
166 | "marker": 61785,
167 | "star-filled": 61781,
168 | "star-half": 62553,
169 | "star-empty": 61780,
170 | "flag": 61991,
171 | "info": 62280,
172 | "warning": 62772,
173 | "share": 62007,
174 | "share1": 62007,
175 | "share-alt": 62016,
176 | "share-alt2": 62018,
177 | "twitter": 62209,
178 | "rss": 62211,
179 | "email": 62565,
180 | "email-alt": 62566,
181 | "facebook": 62212,
182 | "facebook-alt": 62213,
183 | "networking": 62245,
184 | "googleplus": 62562,
185 | "location": 62000,
186 | "location-alt": 62001,
187 | "camera": 62214,
188 | "images-alt": 62002,
189 | "images-alt2": 62003,
190 | "video-alt": 62004,
191 | "video-alt2": 62005,
192 | "video-alt3": 62006,
193 | "vault": 61816,
194 | "shield": 62258,
195 | "shield-alt": 62260,
196 | "sos": 62568,
197 | "search": 61817,
198 | "slides": 61825,
199 | "analytics": 61827,
200 | "chart-pie": 61828,
201 | "chart-bar": 61829,
202 | "chart-line": 62008,
203 | "chart-area": 62009,
204 | "groups": 62215,
205 | "businessman": 62264,
206 | "id": 62262,
207 | "id-alt": 62263,
208 | "products": 62226,
209 | "awards": 62227,
210 | "forms": 62228,
211 | "testimonial": 62579,
212 | "portfolio": 62242,
213 | "book": 62256,
214 | "book-alt": 62257,
215 | "download": 62230,
216 | "upload": 62231,
217 | "backup": 62241,
218 | "clock": 62569,
219 | "lightbulb": 62265,
220 | "microphone": 62594,
221 | "desktop": 62578,
222 | "laptop": 62791,
223 | "tablet": 62577,
224 | "smartphone": 62576,
225 | "phone": 62757,
226 | "smiley": 62248,
227 | "index-card": 62736,
228 | "carrot": 62737,
229 | "building": 62738,
230 | "store": 62739,
231 | "album": 62740,
232 | "palmtree": 62759,
233 | "tickets-alt": 62756,
234 | "money": 62758,
235 | "thumbs-up": 62761,
236 | "thumbs-down": 62786,
237 | "layout": 62776,
238 | "paperclip": 62790,
239 | "email-alt2": 62567,
240 | "menu-alt": 61992,
241 | "trash": 61826,
242 | "heading": 61710,
243 | "insert": 61711,
244 | "align-full-width": 61716,
245 | "button": 61722,
246 | "align-wide": 61723,
247 | "ellipsis": 61724,
248 | "buddicons-activity": 62546,
249 | "buddicons-buddypress-logo": 62536,
250 | "buddicons-community": 62547,
251 | "buddicons-forums": 62537,
252 | "buddicons-friends": 62548,
253 | "buddicons-groups": 62550,
254 | "buddicons-pm": 62551,
255 | "buddicons-replies": 62545,
256 | "buddicons-topics": 62544,
257 | "buddicons-tracking": 62549,
258 | "admin-site-alt": 61725,
259 | "admin-site-alt2": 61726,
260 | "admin-site-alt3": 61727,
261 | "rest-api": 61732,
262 | "yes-alt": 61738,
263 | "buddicons-bbpress-logo": 62583,
264 | "tide": 61709,
265 | "editor-ol-rtl": 61740,
266 | "instagram": 61741,
267 | "businessperson": 61742,
268 | "businesswoman": 61743,
269 | "color-picker": 61745,
270 | "camera-alt": 61737,
271 | "editor-ltr": 61708,
272 | "cloud": 61814,
273 | "twitter-alt": 62210,
274 | "menu-alt2": 62249,
275 | "menu-alt3": 62281,
276 | "plugins-checked": 62597,
277 | "text-page": 61729,
278 | "update-alt": 61715,
279 | "code-standards": 61754,
280 | "align-pull-left": 61706,
281 | "align-pull-right": 61707,
282 | "block-default": 61739,
283 | "cloud-saved": 61751,
284 | "cloud-upload": 61755,
285 | "columns": 61756,
286 | "cover-image": 61757,
287 | "embed-audio": 61758,
288 | "embed-generic": 61759,
289 | "embed-photo": 61764,
290 | "embed-post": 61766,
291 | "embed-video": 61769,
292 | "exit": 61770,
293 | "html": 61771,
294 | "info-outline": 61772,
295 | "insert-after": 61773,
296 | "insert-before": 61774,
297 | "remove": 61775,
298 | "shortcode": 61776,
299 | "table-col-after": 61777,
300 | "table-col-before": 61778,
301 | "table-col-delete": 61786,
302 | "table-row-after": 61787,
303 | "table-row-before": 61788,
304 | "table-row-delete": 61789,
305 | "saved": 61790,
306 | "airplane": 61791,
307 | "amazon": 61794,
308 | "bank": 61802,
309 | "beer": 61804,
310 | "bell": 61805,
311 | "calculator": 61806,
312 | "coffee": 61807,
313 | "database-add": 61808,
314 | "database-export": 61818,
315 | "database-import": 61819,
316 | "database-remove": 61820,
317 | "database-view": 61821,
318 | "database": 61822,
319 | "drumstick": 61823,
320 | "edit-page": 61830,
321 | "food": 61831,
322 | "fullscreen-alt": 61832,
323 | "fullscreen-exit-alt": 61833,
324 | "games": 61834,
325 | "google": 61835,
326 | "hourglass": 61836,
327 | "linkedin": 61837,
328 | "money-alt": 61838,
329 | "open-folder": 61839,
330 | "pdf": 61840,
331 | "pets": 61841,
332 | "pinterest": 61842,
333 | "printer": 61843,
334 | "privacy": 61844,
335 | "reddit": 61845,
336 | "spotify": 61846,
337 | "superhero-alt": 61847,
338 | "superhero": 61848,
339 | "twitch": 61849,
340 | "whatsapp": 61850,
341 | "youtube": 61851,
342 | "car": 61803,
343 | "podio": 61852,
344 | "xing": 61853
345 | }
346 |
--------------------------------------------------------------------------------