PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / php / wordpress / WPUtils.php

WPUtils.php in 404 Solution trunk, at includes/php/wordpress/WPUtils.php

352 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 class ABJ_404_Solution_WPUtils {
9
10 /** @var array<string, callable> */
11 static $actionsAlreadyAdded = array();
12
13 /** Wrapper for the add_action function that throws an exception if the action already exists.
14 *
15 * @global type $wp_filter
16 * @param string $tag The name of the action to which the $function_to_add is hooked.
17 * @param callable $function_to_add The name of the function you wish to be called.
18 * @param int $priority Optional. Used to specify the order in which the functions
19 * associated with a particular action are executed. Default 10.
20 * Lower numbers correspond with earlier execution,
21 * and functions with the same priority are executed
22 * in the order in which they were added to the action.
23 * @param int $accepted_args Optional. The number of arguments the function accepts. Default 1.
24 * @return mixed Whatever add_action() returns.
25 */
26 static function safeAddAction($tag, $function_to_add, $priority = 10, $accepted_args = 1) {
27 global $wp_filter;
28
29 // If we've already added the action then make sure it's the SAME action that we've already
30 // added and that we're not overwriting something.
31 // This isn't strictly necessary but it's cleaner to have
32 // one function instead of two.
33 if (array_key_exists($tag, self::$actionsAlreadyAdded)) {
34 // we already saw the action. check if they're the same.
35 $shouldError = true;
36 if (array_key_exists($tag, self::$actionsAlreadyAdded)) {
37 $functionAlreadyAdded = self::$actionsAlreadyAdded[$tag];
38 // Callables stored here are always arrays ([class/object, method])
39 $existingArr = is_array($functionAlreadyAdded) ? $functionAlreadyAdded : array($functionAlreadyAdded);
40 $newArr = is_array($function_to_add) ? $function_to_add : array($function_to_add);
41 $differences = array_udiff($existingArr, $newArr,
42 array(self::class, 'compareAjaxActionArrays'));
43
44 // any differences mean we accidentally registered the same action to do
45 // two different things. If the differences are 0 then we've accidentally registered
46 // the same action multiple times.
47 if (empty($differences)) {
48 $shouldError = false;
49 }
50 }
51
52 if ($shouldError) {
53 throw new \Exception("I can't add the action " . $tag .
54 " because someone has already registered that tag. Here's what the existing action looks like: " .
55 (string)json_encode($wp_filter[$tag], JSON_PRETTY_PRINT));
56 }
57 }
58
59 self::$actionsAlreadyAdded[$tag] = $function_to_add;
60 return add_action($tag, $function_to_add, $priority, $accepted_args);
61 }
62
63 /**
64 * @param mixed $a
65 * @param mixed $b
66 * @return int
67 */
68 public static function compareAjaxActionArrays($a, $b): int {
69 $str1 = self::getValueOrObjectClass($a);
70 $str2 = self::getValueOrObjectClass($b);
71
72 return strcmp($str1, $str2);
73 }
74
75 /**
76 * Return a string representation of the given WP_Error object.
77 *
78 * If the argument is not a WP_Error object, return a string indicating that.
79 *
80 * Otherwise, return a string that includes the following information:
81 *
82 * - code: the error code
83 * - message: the error message
84 * - data: the error data (using var_export)
85 * - all error codes: each code and its associated messages
86 * - backtrace: the backtrace at the time of calling (using print_r on debug_backtrace)
87 *
88 * If any of the above information cannot be retrieved, include an error message
89 * indicating that.
90 *
91 * @param WP_Error $error The object to stringify.
92 * @return string A string representation of the object.
93 */
94 static function stringify_wp_error($error) {
95 $output = "WP_Error object:\n";
96
97 try {
98 $output .= "Code: " . $error->get_error_code() . "\n";
99 } catch (Throwable $e) {
100 $output .= "Code: [error getting code: " . $e->getMessage() . "]\n";
101 }
102
103 try {
104 $output .= "Message: " . $error->get_error_message() . "\n";
105 } catch (Throwable $e) {
106 $output .= "Message: [error getting message: " . $e->getMessage() . "]\n";
107 }
108
109 try {
110 $data = $error->get_error_data();
111 if (is_array($data) || is_object($data)) {
112 $output .= "Data: " . print_r($data, true) . "\n";
113 } else {
114 $output .= "Data: " . var_export($data, true) . "\n";
115 }
116 } catch (Throwable $e) {
117 $output .= "Data: [error getting data: " . $e->getMessage() . "]\n";
118 }
119
120 try {
121 $codes = $error->get_error_codes();
122 $output .= "All error codes:\n";
123 foreach ($codes as $code) {
124 try {
125 $messages = $error->get_error_messages($code);
126 $output .= "- $code: " . implode("; ", $messages) . "\n";
127 } catch (Throwable $e) {
128 $output .= "- $code: [error getting messages: " . $e->getMessage() . "]\n";
129 }
130 }
131 } catch (Throwable $e) {
132 $output .= "All error codes: [error fetching codes: " . $e->getMessage() . "]\n";
133 }
134
135 try {
136 $output .= "Backtrace:\n" . print_r(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), true) . "\n";
137 } catch (Throwable $e) {
138 $output .= "Backtrace: [error generating backtrace: " . $e->getMessage() . "]\n";
139 }
140
141 return $output;
142 }
143
144 /**
145 * Gets a string representation of a callable for comparison.
146 *
147 * @param mixed $callable The callable (string function name, array [object/class, method], Closure).
148 * @return string A string representation.
149 */
150 private static function getValueOrObjectClass($callable) {
151 if (is_string($callable)) {
152 // Simple function name
153 return trim($callable);
154 } elseif (is_array($callable) && count($callable) === 2) {
155 // Array callable: [object/class, method]
156 $classOrObject = $callable[0];
157 $method = is_string($callable[1]) ? $callable[1] : '';
158 if (is_object($classOrObject)) {
159 // Instance method: [new ClassName(), 'methodName']
160 return get_class($classOrObject) . '::' . trim($method);
161 } elseif (is_string($classOrObject)) {
162 // Static method: ['ClassName', 'methodName']
163 return trim($classOrObject) . '::' . trim($method);
164 }
165 } elseif ($callable instanceof \Closure) {
166 // It's a Closure (anonymous function). Comparing these reliably is tricky.
167 // Returning a generic placeholder might be sufficient if you don't expect
168 // multiple different closures on the same hook tag.
169 // Alternatively, use spl_object_hash for a unique ID per instance,
170 // but note this hash can change between requests.
171 return 'Closure#' . spl_object_hash($callable);
172 }
173
174 // Fallback for unexpected types - you might want to log or throw an error here
175 return serialize($callable);
176 }
177
178 /** Set the version to the file date/time.
179 * @param string $handle
180 * @param string $src
181 * @param array<int, string> $deps
182 * @param string|bool $ver
183 * @param bool $in_footer
184 * @return void
185 */
186 static function my_wp_enq_scrpt(string $handle, string $src = '', array $deps = array(),
187 $ver = false, bool $in_footer = false): void {
188
189 $ver = ABJ_404_Solution_WPUtils::createUpdatedVersionNumber($src, $ver);
190
191 wp_enqueue_script($handle, $src, $deps, $ver, $in_footer);
192 }
193
194 /** Set the version to the file date/time.
195 * @param string $handle
196 * @param string $src
197 * @param array<int, string> $deps
198 * @param string|bool $ver
199 * @param string $media
200 * @return void
201 */
202 static function my_wp_enq_style(string $handle, string $src = '', array $deps = array(), $ver = false, string $media = 'all'): void {
203 $ver = ABJ_404_Solution_WPUtils::createUpdatedVersionNumber($src, $ver);
204
205 wp_enqueue_style($handle, $src, $deps, $ver, $media);
206 }
207
208 /** This forces the version number of a file to be the modified date of that
209 * file. It gets the local file location by changing the URL, gets the modified
210 * date, then returns that date as a string for the version number.
211 * @param string $src
212 * @param string|bool $ver
213 * @return string|false
214 */
215 static function createUpdatedVersionNumber($src = '', $ver = false) {
216 // if there's no version number and the file is for our plugin
217 if ($ver === false && ($src != null && $src != '' &&
218 strpos($src, ABJ404_URL) === 0)) {
219
220 // get the local file path by changing the URL.
221 $correctedFilePath = str_replace(ABJ404_URL, ABJ404_PATH, $src);
222 // get the modified date as the version (guard missing files in tests/odd installs).
223 if (is_string($correctedFilePath) && is_file($correctedFilePath)) {
224 $mtime = @filemtime($correctedFilePath);
225 if ($mtime !== false) {
226 $ver = date('Y-m-d_H:i:s', $mtime);
227 }
228 }
229 }
230
231 if (is_string($ver)) {
232 return $ver;
233 }
234 return false;
235 }
236
237 /** Text domain shared by every translated asset in this plugin. */
238 const TEXT_DOMAIN = '404-solution';
239
240 /** Register wp.i18n translations for every script handle this screen enqueued.
241 *
242 * wp_set_script_translations() looks for languages/404-solution-{locale}-{handle}.json
243 * (built from the .po catalogs by scripts/build-script-translations.php) and
244 * prints a wp.i18n.setLocaleData() call ahead of the handle, so the JS __() calls
245 * resolve against the same catalog the PHP side uses. It is a no-op for a handle
246 * that the current screen never registered, so calling this once per enqueue
247 * context covers every screen without per-screen branching.
248 *
249 * @return void
250 */
251 static function registerScriptTranslations(): void {
252 if (!function_exists('wp_set_script_translations')) {
253 return;
254 }
255
256 self::addPluginLocaleScriptTranslationFilter();
257
258 $languagesDir = defined('ABJ404_PATH') ? ABJ404_PATH . 'languages' :
259 dirname(dirname(dirname(__DIR__))) . '/languages';
260 foreach (array_keys(self::scriptTranslationHandles()) as $handle) {
261 wp_set_script_translations($handle, self::TEXT_DOMAIN, $languagesDir);
262 }
263 }
264
265 /** The handle-to-JS-source map shared by the runtime, the JSON builder and the tests.
266 *
267 * @return array<string, string>
268 */
269 static function scriptTranslationHandles(): array {
270 $dataFile = (defined('ABJ404_PATH') ? ABJ404_PATH : dirname(dirname(dirname(__DIR__))) . '/') .
271 'includes/data/script-translation-handles.php';
272 if (!is_file($dataFile)) {
273 return array();
274 }
275 $handles = include $dataFile;
276 if (!is_array($handles)) {
277 return array();
278 }
279 // Validate the shape rather than trusting the include. A data file that was
280 // truncated or edited into the wrong shape would otherwise reach
281 // wp_set_script_translations() as a non-string handle, where it fails silently
282 // and the modal renders in English with nothing in any log. Dropping the bad
283 // entries here means the worst case is a missing translation the positive
284 // control in ScriptTranslationsTest already fails on.
285 $typed = array();
286 foreach ($handles as $handle => $jsSource) {
287 if (is_string($handle) && is_string($jsSource)) {
288 $typed[$handle] = $jsSource;
289 }
290 }
291 return $typed;
292 }
293
294 /** Register the filter that honours the plugin's own language override for JS strings.
295 *
296 * The "Plugin Language Override" setting is applied to PHP translations through the
297 * plugin_locale filter (abj404_override_plugin_locale). WordPress builds the script
298 * translation filename from determine_locale() instead, which does not see that
299 * override, so without this the admin page would render in the chosen language while
300 * the modal stayed in the site language. Rewriting the filename keeps both sides on
301 * one locale decision rather than introducing a second one.
302 *
303 * @return void
304 */
305 private static function addPluginLocaleScriptTranslationFilter(): void {
306 static $added = false;
307 if ($added || !function_exists('add_filter')) {
308 return;
309 }
310 $added = true;
311 add_filter('load_script_translation_file',
312 array('ABJ_404_Solution_WPUtils', 'useOverriddenPluginLocaleForScriptTranslations'), 10, 3);
313 }
314
315 /** Point a script-translation lookup at the overridden plugin locale when one is set.
316 *
317 * Falls through to the unmodified path whenever no override applies or the overridden
318 * locale has no JSON file, so a missing override catalog degrades to the site locale
319 * rather than to no translations at all.
320 *
321 * @param mixed $file Absolute path WordPress resolved for the current locale.
322 * @param string $handle Script handle being translated.
323 * @param string $domain Text domain being translated.
324 * @return mixed The path to load.
325 */
326 static function useOverriddenPluginLocaleForScriptTranslations($file, $handle, $domain) {
327 if ($domain !== self::TEXT_DOMAIN || !is_string($file) || $file === '') {
328 return $file;
329 }
330
331 $locale = function_exists('determine_locale') ? determine_locale() :
332 (function_exists('get_locale') ? get_locale() : '');
333 if (!is_string($locale) || $locale === '') {
334 return $file;
335 }
336 $pluginLocale = apply_filters('plugin_locale', $locale, self::TEXT_DOMAIN);
337 if (!is_string($pluginLocale) || $pluginLocale === '' || $pluginLocale === $locale) {
338 return $file;
339 }
340
341 $prefix = self::TEXT_DOMAIN . '-' . $locale . '-';
342 $base = basename($file);
343 if (strpos($base, $prefix) !== 0) {
344 return $file;
345 }
346 $overridden = dirname($file) . '/' . self::TEXT_DOMAIN . '-' . $pluginLocale . '-' .
347 substr($base, strlen($prefix));
348 return is_file($overridden) ? $overridden : $file;
349 }
350
351 }
352