PluginProbe
Alt Text Tools / trunk
Alt Text Tools vtrunk
0.4.0 trunk 0.2.0 0.3.0
alt-text-tools / alt-text-tools.php

alt-text-tools.php in Alt Text Tools trunk, at alt-text-tools.php

467 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /*
3 Plugin Name: Alt Text Tools
4 Description: Exports a CSV file of all images that are actually used in your content, along with their corresponding alt tags.
5 Version: 0.4.0
6 Author: NerdPress
7 Author URI: https://www.nerdpress.net
8 License: GPLv2
9 */
10
11 if ( ! defined( 'ABSPATH' ) )
12 die( 'YOU SHALL NOT PASS' );
13
14 if ( ! defined( 'NP_ATT_VERSION' ) )
15 define( 'NP_ATT_VERSION', '0.4.0' );
16
17 class NerdpressAltTextTools {
18 /**
19 * Dedicated custom capability that also grants access to Alt Text Tools.
20 *
21 * This is intentionally only *honored*, never registered or assigned to
22 * any role by the plugin -- so there's no database bloat and no
23 * activation/deactivation lifecycle to manage. It grants access only if a
24 * site administrator deliberately creates and assigns it (for example with
25 * a plugin like User Role Editor), which gives a no-code way to grant
26 * access to an arbitrary role or user.
27 *
28 * @since 0.4.0
29 */
30 const CUSTOM_CAP = 'manage_alt_text_tools';
31
32 /**
33 * @var string. Plugins PRO Api Key
34 *
35 * This is reserved for the tiered features
36 *
37 * @since 0.0.1
38 */
39 private $apikey;
40
41 /**
42 * Class' initializer
43 *
44 * @since 0.0.1
45 */
46 public static function init() {
47 $class = __CLASS__;
48 new $class;
49 }
50
51 /**
52 * The filterable base capability required to access Alt Text Tools.
53 *
54 * Defaults to 'manage_options' (Administrators only), matching the
55 * plugin's long-standing behavior -- updating the plugin does not expose
56 * the tool to any role that couldn't already use it.
57 *
58 * Filter this to require a different capability instead (a full override).
59 * For example, to open access to Editors:
60 *
61 * add_filter( 'nerdpress_alt_text_tools_cap', function() {
62 * return 'edit_others_posts';
63 * } );
64 *
65 * ...or to open access down to Authors and up ('publish_posts' excludes
66 * Contributors; use 'edit_posts' if you want Contributors included too):
67 *
68 * add_filter( 'nerdpress_alt_text_tools_cap', function() {
69 * return 'publish_posts';
70 * } );
71 *
72 * If you'd rather grant access without writing code, assign the dedicated
73 * NerdpressAltTextTools::CUSTOM_CAP ('manage_alt_text_tools') capability to
74 * a role or user with a plugin like User Role Editor -- see
75 * currentUserCanAccess().
76 *
77 * @since 0.4.0
78 *
79 * @return string The base capability required to access the tool.
80 */
81 public static function getCapability() {
82 return apply_filters( 'nerdpress_alt_text_tools_cap', 'manage_options' );
83 }
84
85 /**
86 * Whether the current user may access Alt Text Tools.
87 *
88 * Access is granted if the user has EITHER the filterable base capability
89 * (see getCapability()) OR the dedicated custom capability (see
90 * CUSTOM_CAP). The custom capability is what makes no-code, per-role access
91 * possible via a role editor.
92 *
93 * @since 0.4.0
94 *
95 * @return bool
96 */
97 public static function currentUserCanAccess() {
98 return current_user_can( self::getCapability() ) || current_user_can( self::CUSTOM_CAP );
99 }
100
101 /**
102 * A single capability string the current user holds, for use with
103 * add_management_page() (which accepts only one capability).
104 *
105 * Should only be called for a user who already passes currentUserCanAccess().
106 *
107 * @since 0.4.0
108 *
109 * @return string
110 */
111 private static function menuCapability() {
112 return current_user_can( self::getCapability() ) ? self::getCapability() : self::CUSTOM_CAP;
113 }
114
115 /**
116 * Constructor
117 *
118 * @since 0.0.1
119 */
120 public function __construct() {
121 if ( ! self::currentUserCanAccess() )
122 return;
123
124 add_action( 'admin_menu', array( $this, 'settingsPage' ) );
125 add_action( 'wp_ajax_getCsv', array( $this, 'getCsv' ) );
126
127 }
128
129 /**
130 * Inject JS script(s) into the settings page
131 *
132 * @since 0.0.1
133 */
134 public function injectScripts() {
135 wp_register_script( 'np_alt_tools_js', plugins_url( 'js/np_alt_tools.js', __FILE__ ), array( 'jquery' ), NP_ATT_VERSION );
136 wp_localize_script( 'np_alt_tools_js', 'np_alt_tools', array(
137 'endpoint' => admin_url( 'admin-ajax.php' ),
138 'nonce' => wp_create_nonce( 'np_alt_tools_secure_me' )
139 ) );
140 wp_enqueue_script( 'np_alt_tools_js' );
141 }
142
143 /**
144 * Plugin settings page (where most of the action happens ;)
145 * Use injectScripts to insert admin scripts only on the settings page
146 *
147 * @since 0.0.1
148 */
149 public function settingsPage() {
150 $hookSuffix = add_management_page(
151 'Alt Text Tools',
152 'Alt Text Tools',
153 self::menuCapability(),
154 'nerdpress-alt-text-tools',
155 array( $this, 'settingsHtml' )
156 );
157 add_action( 'admin_print_scripts-' . $hookSuffix, array( $this, 'injectScripts' ) );
158 }
159
160 /**
161 * Markup for the settings page
162 *
163 * @since 0.0.1
164 */
165 public function settingsHtml() {
166 ?>
167 <div class="wrap">
168 <h1>NerdPress Alt Text Tools</h1>
169 <div class="button" id="npatt-csv-action" style="margin-top:30px">Download Alt Tag CSV</div>
170 </div>
171 <?php
172 }
173
174 private static function escapeCsv( $fields_array ) {
175 $result = [];
176 foreach( $fields_array as $field => $value ) {
177 // Escape double quotes by doubling them.
178 $value = str_replace( '"', '""', $value );
179
180 // Enclose in double quotes if it contains special characters
181 if ( preg_match( '/[,"\r\n]/', $value ) ) {
182 $value = '"' . $value . '"';
183 }
184
185 $result[] = $value;
186 }
187 return $result;
188 }
189
190 private static function getImageNameFromUrl( $img_url ) {
191 $path_parts = explode( '/', $img_url );
192 $len = count( $path_parts );
193 if ( empty( $path_parts[ $len - 1 ] ) )
194 $image_name = $path_parts[ $len - 2 ];
195 else
196 $image_name = $path_parts[ $len - 1 ];
197
198 // Strip the size of the image
199 return preg_replace( '/-[0-9]+x[0-9]+/', '', $image_name );
200 }
201
202 /**
203 * The CSV columns, in output order: internal key => human header.
204 *
205 * The internal keys are also the keys of each associative row returned by
206 * buildRows(), which keeps the data layer self-describing for structured
207 * consumers (e.g. the Abilities API).
208 *
209 * @return array
210 */
211 private static function csvColumns() {
212 return array(
213 'post_id' => 'post id',
214 'post_type' => 'post_type',
215 'page_title' => 'page title',
216 'page_url' => 'page url',
217 'image_url' => 'image url',
218 'alt' => 'image alt tag',
219 'edit_link' => 'edit link',
220 'media_link' => 'media library link',
221 );
222 }
223
224 /**
225 * Scan all public content and return every discovered image as an
226 * associative row (keyed by self::csvColumns() keys).
227 *
228 * This is the pure data layer -- it performs no request handling or
229 * output, so it can back both the AJAX download (getCsv()) and the
230 * Abilities API ability. The 'alt' value is the image's alt text, or the
231 * sentinel 'MISSING' (no alt attribute) / 'EMPTY' (alt="").
232 *
233 * @return array[] List of associative rows.
234 */
235 public static function buildRows() {
236 /*=============================
237 * Get all the posts/pages etc.
238 *=============================*/
239 $opts = array(
240 'public' => TRUE,
241 '_builtin' => FALSE
242 );
243 $postTypes = array_merge(
244 array( 'post', 'page' ),
245 get_post_types( $opts )
246 );
247
248 $posts = [];
249 foreach( $postTypes as $type ) {
250 $posts = array_merge( $posts, get_posts( array(
251 'posts_per_page' => -1,
252 'post_type' => $type
253 ) ) );
254 }
255 sort( $posts );
256
257 /*============================
258 * Find images, src, alt tags.
259 *============================*/
260 $imgElemRegex = '/<img[^>]*>/';
261 $site_url = site_url();
262 $finds = [];
263 foreach( $posts as $post ) {
264 $post_link = get_permalink( $post );
265 $post_title = get_the_title( $post );
266 $post_edit_link = get_edit_post_link( $post );
267 $found = preg_match_all(
268 $imgElemRegex,
269 apply_filters( 'the_content', $post->post_content ),
270 $matches,
271 PREG_PATTERN_ORDER
272 );
273
274 if ( ! $found ) continue;
275
276 foreach( $matches as $match ) {
277 foreach( $match as $submatch ) {
278 preg_match( '/src="([^"]*)"/', $submatch, $img_url );
279 if ( strpos( $img_url[ 1 ], $site_url ) !== FALSE )
280 $media_link = $site_url .'/wp-admin/upload.php?s=' . self::getImageNameFromUrl( $img_url[ 1 ] );
281 else
282 $media_link = '';
283
284 preg_match( '/alt="([^"]*)"/', $submatch, $alt_tag );
285 if ( ! $alt_tag )
286 $alt_tag = [ '', 'MISSING' ];
287 else if ( $alt_tag[1] == '' )
288 $alt_tag = [ '', 'EMPTY' ];
289
290 $finds[] = array(
291 'post_id' => $post->ID,
292 'post_type' => $post->post_type,
293 'page_title' => html_entity_decode( $post_title ),
294 'page_url' => html_entity_decode( $post_link ),
295 'image_url' => html_entity_decode( $img_url[1] ),
296 'alt' => html_entity_decode( $alt_tag[1] ),
297 'edit_link' => html_entity_decode( $post_edit_link ),
298 'media_link' => html_entity_decode( $media_link )
299 );
300 }
301 }
302 }
303
304 return $finds;
305 }
306
307 /**
308 * Assemble and return the CSV file contents.
309 *
310 * Returned (not echoed) so it can serve as an Abilities API
311 * execute_callback as well as backing the AJAX download.
312 *
313 * @return string CSV content, rows separated by CRLF.
314 */
315 public static function buildCsvString() {
316 $columns = self::csvColumns();
317 $keys = array_keys( $columns );
318
319 $rows = array( implode( ',', array_values( $columns ) ) );
320
321 foreach( self::buildRows() as $find ) {
322 $ordered = array();
323 foreach( $keys as $key ) {
324 $ordered[] = $find[ $key ];
325 }
326 $rows[] = implode( ',', self::escapeCsv( $ordered ) );
327 }
328
329 return implode( "\r\n", $rows );
330 }
331
332 /**
333 * Abilities API execute_callback: the audit as a structured JSON object.
334 *
335 * Wrapping the rows in an object keeps the output conformant with the MCP
336 * structured-output contract (object outputSchema + structuredContent),
337 * which a bare array or string could not satisfy.
338 *
339 * @return array{rows: array[]}
340 */
341 public static function exportAltText() {
342 return array( 'rows' => self::buildRows() );
343 }
344
345 /**
346 * AJAX handler: verify the nonce and stream the CSV back to the browser.
347 *
348 * @since 0.0.1
349 */
350 public static function getCsv() {
351 check_ajax_referer( 'np_alt_tools_secure_me', 'np_alt_tools_nonce' );
352
353 echo self::buildCsvString();
354 die();
355 }
356
357 /**
358 * Register the ability category with the WordPress Abilities API.
359 *
360 * Must run on `wp_abilities_api_categories_init` — wp_register_ability_category()
361 * refuses (with _doing_it_wrong) to run during any other action, and
362 * wp_register_ability() rejects an ability whose category is unregistered,
363 * so registering the category on the wrong hook silently drops the
364 * ability too.
365 */
366 public static function registerAbilityCategories() {
367 if ( ! function_exists( 'wp_register_ability_category' ) )
368 return;
369
370 wp_register_ability_category( 'alt-text-tools', array(
371 'label' => __( 'Alt Text Tools', 'alt-text-tools' ),
372 'description' => __( 'Accessibility auditing for image alt text.', 'alt-text-tools' ),
373 ) );
374 }
375
376 /**
377 * Register the alt text export with the WordPress Abilities API.
378 *
379 * Registration is deliberately independent of the admin-menu wiring in the
380 * constructor (which only runs for users who can already access the tool):
381 * an ability must be registered for everyone, and the permission_callback
382 * gates each individual invocation. The function_exists() guard keeps this
383 * a no-op on WordPress installs without the Abilities API.
384 */
385 public static function registerAbilities() {
386 if ( ! function_exists( 'wp_register_ability' ) )
387 return;
388
389 wp_register_ability( 'alt-text-tools/export-alt-text', array(
390 'label' => __( 'Export Alt Text', 'alt-text-tools' ),
391 'description' => __( 'Read-only accessibility audit. Scans every <img> in the rendered content of all public posts, pages, and custom post types, and returns a JSON object with a "rows" array -- one entry per image occurrence, so an image used N times yields N entries. Includes external/hotlinked and decorative images. Each row\'s "alt" field holds the alt text, or the sentinel MISSING (no alt attribute) or EMPTY (alt=""). Takes no parameters.', 'alt-text-tools' ),
392 'category' => 'alt-text-tools',
393 'input_schema' => array(
394 'type' => 'object',
395 'properties' => array(),
396 'additionalProperties' => false,
397 ),
398 'output_schema' => array(
399 'type' => 'object',
400 'properties' => array(
401 'rows' => array(
402 'type' => 'array',
403 'description' => __( 'One entry per image occurrence, in the order the images were found. An image used N times yields N entries.', 'alt-text-tools' ),
404 'items' => array(
405 'type' => 'object',
406 'properties' => array(
407 'post_id' => array(
408 'type' => 'integer',
409 'description' => __( 'ID of the post the image appears in.', 'alt-text-tools' ),
410 ),
411 'post_type' => array(
412 'type' => 'string',
413 'description' => __( 'Post type slug (e.g. post, page, or a custom type).', 'alt-text-tools' ),
414 ),
415 'page_title' => array(
416 'type' => 'string',
417 'description' => __( 'Title of the post.', 'alt-text-tools' ),
418 ),
419 'page_url' => array(
420 'type' => 'string',
421 'description' => __( 'Permalink of the post.', 'alt-text-tools' ),
422 ),
423 'image_url' => array(
424 'type' => 'string',
425 'description' => __( 'Source URL of the image.', 'alt-text-tools' ),
426 ),
427 'alt' => array(
428 'type' => 'string',
429 'description' => __( 'Alt text, or the sentinel MISSING (no alt attribute) or EMPTY (alt="").', 'alt-text-tools' ),
430 ),
431 'edit_link' => array(
432 'type' => 'string',
433 'description' => __( 'Admin edit link for the post.', 'alt-text-tools' ),
434 ),
435 'media_link' => array(
436 'type' => 'string',
437 'description' => __( 'Media library search link for the image, or an empty string for external/hotlinked images not in this site\'s media library.', 'alt-text-tools' ),
438 ),
439 ),
440 'required' => array( 'post_id', 'post_type', 'page_title', 'page_url', 'image_url', 'alt', 'edit_link', 'media_link' ),
441 'additionalProperties' => false,
442 ),
443 ),
444 ),
445 'required' => array( 'rows' ),
446 'additionalProperties' => false,
447 ),
448 'execute_callback' => array( __CLASS__, 'exportAltText' ),
449 'permission_callback' => array( __CLASS__, 'currentUserCanAccess' ),
450 'meta' => array(
451 'show_in_rest' => true,
452 // Without these, consumers must assume the WordPress defaults
453 // (readonly: false, destructive: true) and gate this pure read
454 // as a destructive write.
455 'annotations' => array(
456 'readonly' => true,
457 'destructive' => false,
458 ),
459 ),
460 ) );
461 }
462 }
463
464 add_action( 'plugins_loaded', array( 'NerdpressAltTextTools', 'init' ) );
465 add_action( 'wp_abilities_api_categories_init', array( 'NerdpressAltTextTools', 'registerAbilityCategories' ) );
466 add_action( 'wp_abilities_api_init', array( 'NerdpressAltTextTools', 'registerAbilities' ) );
467