PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 0.7.0 All 126 releases
extendify / app / Agent / Controllers / ContentController.php

ContentController.php in Extendify 3.1.5, at app/Agent/Controllers/ContentController.php

360 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Agent\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 class ContentController
8 {
9 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
10 // Header and footer text lives in a template part, a synced pattern's in a wp_block.
11 const POST_TYPES = ['post', 'page', 'wp_template_part', 'wp_block'];
12
13 const FORMATS = ['text', 'blocks', 'excerpt'];
14
15 const MAX_SCANNED = 100;
16
17 const MAX_POSTS = 25;
18
19 const MAX_MATCHES = 3;
20
21 const MAX_CONTEXT = 300;
22
23 const MAX_CONTENT = 15000;
24
25 const MAX_BYTES = 20000;
26
27 const MAX_META_KEYS = 50;
28
29 const MAX_META_VALUE = 500;
30 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
31
32 /**
33 * @param \WP_REST_Request $request The REST API request object.
34 * @return \WP_REST_Response
35 */
36 public static function search(\WP_REST_Request $request)
37 {
38 $params = self::params($request);
39 $query = trim(self::text($params, 'query'));
40 if ($query === '') {
41 return self::reject('query must say what to look for');
42 }
43
44 $types = self::types($params);
45 if (!$types) {
46 return self::reject('postTypes names no post type this site has');
47 }
48
49 $limit = self::number($params, 'limit', 10, self::MAX_POSTS);
50 $context = self::number($params, 'context', 60, self::MAX_CONTEXT);
51 $candidates = new \WP_Query([
52 'post_type' => $types,
53 'post_status' => 'publish',
54 'posts_per_page' => self::MAX_SCANNED,
55 'orderby' => 'modified',
56 'order' => 'DESC',
57 'ignore_sticky_posts' => true,
58 ]);
59
60 $results = [];
61 $matched = 0;
62 $spent = 0;
63 foreach ($candidates->posts as $post) {
64 $found = self::snippets(self::rendered($post), $query, $context);
65 if (!$found['matches']) {
66 continue;
67 }
68
69 $matched++;
70 if (count($results) >= $limit || $spent > self::MAX_BYTES) {
71 continue;
72 }
73
74 $result = [
75 'id' => (int) $post->ID,
76 'type' => $post->post_type,
77 'title' => self::decoded($post->post_title),
78 'slug' => $post->post_name,
79 'matches' => $found['matches'],
80 'moreMatches' => $found['count'] - count($found['matches']),
81 ];
82 // Escaped JSON counts one Japanese character as six, spending the budget six times over.
83 $spent += strlen((string) wp_json_encode($result, JSON_UNESCAPED_UNICODE));
84 $results[] = $result;
85 }
86
87 $response = [
88 'query' => $query,
89 'scanned' => count($candidates->posts),
90 'matched' => $matched,
91 'results' => $results,
92 ];
93 $left = self::leftOut($matched - count($results), (int) $candidates->found_posts - count($candidates->posts));
94 if ($left !== '') {
95 $response['truncated'] = $left;
96 }
97
98 return new \WP_REST_Response($response);
99 }
100
101 /**
102 * @param \WP_REST_Request $request The REST API request object.
103 * @return \WP_REST_Response
104 */
105 public static function read(\WP_REST_Request $request)
106 {
107 $params = self::params($request);
108 $id = self::number($params, 'id', 0, PHP_INT_MAX);
109 $slug = trim(self::text($params, 'slug'));
110 if (!$id && $slug === '') {
111 return self::reject('id or slug must say what to read');
112 }
113
114 $format = self::text($params, 'format', 'text');
115 if (!in_array($format, self::FORMATS, true)) {
116 return self::reject('format must be one of ' . implode(', ', self::FORMATS));
117 }
118
119 $post = $id ? self::byId($id) : self::bySlug($slug);
120 if (!$post) {
121 return self::reject('Nothing readable was found for ' . ($id ? $id : $slug));
122 }
123
124 $content = self::content($post, $format);
125 $response = [
126 'id' => (int) $post->ID,
127 'type' => $post->post_type,
128 'title' => self::decoded($post->post_title),
129 'slug' => $post->post_name,
130 'status' => $post->post_status,
131 'date' => $post->post_date,
132 'format' => $format,
133 'content' => mb_substr($content, 0, self::MAX_CONTENT),
134 ];
135 if (mb_strlen($content) > self::MAX_CONTENT) {
136 $response['truncated'] = 'Cut after ' . self::MAX_CONTENT . ' characters, of '
137 . mb_strlen($content) . ' the post holds.';
138 }
139 if (!empty($params['includeMeta'])) {
140 $response['meta'] = self::meta($post->ID);
141 }
142
143 return new \WP_REST_Response($response);
144 }
145
146 /**
147 * @param \WP_REST_Request $request The REST API request object.
148 * @return array The request body.
149 */
150 private static function params(\WP_REST_Request $request)
151 {
152 $params = $request->get_json_params();
153
154 return is_array($params) ? $params : [];
155 }
156
157 /**
158 * @param string $message What the caller has to change.
159 * @return \WP_REST_Response
160 */
161 private static function reject($message)
162 {
163 return new \WP_REST_Response(['error' => $message], 400);
164 }
165
166 /**
167 * @param array $params The array to read from.
168 * @param string $key The key to read.
169 * @param string $default What to use when the key is absent.
170 * @return string
171 */
172 private static function text($params, $key, $default = '')
173 {
174 return isset($params[$key]) && is_string($params[$key]) ? $params[$key] : $default;
175 }
176
177 /**
178 * @param array $params The array to read from.
179 * @param string $key The key to read.
180 * @param integer $default What to use when the key is absent.
181 * @param integer $most The largest value to allow.
182 * @return integer
183 */
184 private static function number($params, $key, $default, $most)
185 {
186 $value = isset($params[$key]) && is_numeric($params[$key])
187 ? (int) $params[$key]
188 : $default;
189
190 return max(0, min($value, $most));
191 }
192
193 /**
194 * @param array $params The request body.
195 * @return array The post types to search through.
196 */
197 private static function types($params)
198 {
199 $requested = isset($params['postTypes']) && is_array($params['postTypes'])
200 ? $params['postTypes']
201 : [];
202 if (!$requested) {
203 return self::POST_TYPES;
204 }
205
206 return array_values(array_filter($requested, function ($type) {
207 return is_string($type) && post_type_exists($type);
208 }));
209 }
210
211 /**
212 * An empty result and one nobody looked for read the same to a model.
213 *
214 * @param integer $matched Matching posts the response left out.
215 * @param integer $unscanned Candidates the scan never reached.
216 * @return string
217 */
218 private static function leftOut($matched, $unscanned)
219 {
220 $left = [];
221 if ($matched > 0) {
222 $left[] = $matched . ' more posts matched';
223 }
224 if ($unscanned > 0) {
225 $left[] = $unscanned . ' posts were not searched';
226 }
227
228 return $left ? implode(', and ', $left) . '.' : '';
229 }
230
231 /**
232 * Blocks that read the post they sit in render empty without the global.
233 *
234 * @param \WP_Post $post The post to render.
235 * @return string Its text as a visitor reads it.
236 */
237 private static function rendered($post)
238 {
239 $GLOBALS['post'] = $post;
240 setup_postdata($post);
241 $html = do_shortcode(do_blocks($post->post_content));
242 wp_reset_postdata();
243
244 return trim(preg_replace('/\s+/u', ' ', self::decoded(wp_strip_all_tags($html))));
245 }
246
247 /**
248 * @param string $text Text as WordPress stored or rendered it.
249 * @return string
250 */
251 private static function decoded($text)
252 {
253 return html_entity_decode($text, ENT_QUOTES, get_bloginfo('charset'));
254 }
255
256 /**
257 * @param string $text The text to look through.
258 * @param string $query What to look for.
259 * @param integer $context How much to quote either side of a match.
260 * @return array The snippets, and how many times the text says it.
261 */
262 private static function snippets($text, $query, $context)
263 {
264 $length = mb_strlen($query);
265 $end = mb_strlen($text);
266
267 $matches = [];
268 $count = 0;
269 $quoted = 0;
270 $offset = 0;
271 // Turkish İ lower cases to two characters, so a folded copy's offsets miss the match.
272 while (($at = mb_stripos($text, $query, $offset)) !== false) {
273 $count++;
274 $offset = $at + $length;
275 if (count($matches) >= self::MAX_MATCHES || $at < $quoted) {
276 continue;
277 }
278
279 $from = max(0, $at - $context);
280 $quoted = min($end, $at + $length + $context);
281 $matches[] = ($from > 0 ? '' : '')
282 . mb_substr($text, $from, $quoted - $from)
283 . ($quoted < $end ? '' : '');
284 }
285
286 return ['matches' => $matches, 'count' => $count];
287 }
288
289 /**
290 * @param integer $id A post id.
291 * @return \WP_Post|null
292 */
293 private static function byId($id)
294 {
295 $post = get_post($id);
296 if (!$post || in_array($post->post_status, ['auto-draft', 'trash'], true)) {
297 return null;
298 }
299
300 return $post;
301 }
302
303 /**
304 * @param string $slug A post slug.
305 * @return \WP_Post|null
306 */
307 private static function bySlug($slug)
308 {
309 $posts = get_posts([
310 'name' => $slug,
311 'post_type' => self::POST_TYPES,
312 'post_status' => 'any',
313 'posts_per_page' => 1,
314 ]);
315
316 return $posts ? $posts[0] : null;
317 }
318
319 /**
320 * @param \WP_Post $post The post being read.
321 * @param string $format Which of self::FORMATS to return.
322 * @return string
323 */
324 private static function content($post, $format)
325 {
326 if ($format === 'blocks') {
327 return $post->post_content;
328 }
329
330 if ($format === 'excerpt') {
331 return get_the_excerpt($post);
332 }
333
334 return self::rendered($post);
335 }
336
337 /**
338 * Keys under an underscore are WordPress and plugin bookkeeping.
339 *
340 * @param integer $id The post to read meta from.
341 * @return array Name => value pairs.
342 */
343 private static function meta($id)
344 {
345 $meta = [];
346 foreach (get_post_meta($id) as $key => $stored) {
347 if (strpos($key, '_') === 0 || count($meta) >= self::MAX_META_KEYS) {
348 continue;
349 }
350
351 $value = maybe_unserialize(isset($stored[0]) ? $stored[0] : '');
352 $meta[$key] = is_string($value) && mb_strlen($value) > self::MAX_META_VALUE
353 ? mb_substr($value, 0, self::MAX_META_VALUE)
354 : $value;
355 }
356
357 return $meta;
358 }
359 }
360