PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.3
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.3
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / media-optimization / class-media-library-list-table.php

class-media-library-list-table.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.3, at media-optimization/class-media-library-list-table.php

699 lines 24.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Media_Library_List_Table
4 * WP_List_Table for displaying media library images with optimization status.
5 *
6 * @package Search Atlas SEO
7 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
8 * @since 2.6.0
9 */
10
11 if (!defined('ABSPATH')) {
12 exit;
13 }
14
15 if (!class_exists('WP_List_Table')) {
16 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
17 }
18
19 class Metasync_Media_Library_List_Table extends WP_List_Table {
20
21 private const PER_PAGE = 20;
22 private const PAGED_PARAM = 'paged_media';
23 private const ORDERBY_PARAM = 'orderby_media';
24 private const ORDER_PARAM = 'order_media';
25
26 /**
27 * Resolved attachment-to-post relationships for the current page.
28 * Keys are attachment IDs, values are arrays of post IDs.
29 *
30 * @var array<int, int[]>
31 */
32 private array $attachment_posts = [];
33
34 public function __construct() {
35 parent::__construct([
36 'singular' => 'image',
37 'plural' => 'images',
38 'ajax' => false,
39 ]);
40 }
41
42 /**
43 * Get table columns.
44 */
45 public function get_columns(): array {
46 return [
47 'cb' => '<input type="checkbox" />',
48 'image' => __('Image', 'metasync'),
49 'post' => __('Post', 'metasync'),
50 'status' => __('Status', 'metasync'),
51 'actions' => __('Actions', 'metasync'),
52 ];
53 }
54
55 /**
56 * Get sortable columns.
57 */
58 protected function get_sortable_columns(): array {
59 return [
60 'image' => ['title', false],
61 ];
62 }
63
64 /**
65 * Checkbox column for bulk actions.
66 */
67 protected function column_cb($item): string {
68 return sprintf(
69 '<input type="checkbox" name="image_ids[]" value="%d" />',
70 $item->ID
71 );
72 }
73
74 /**
75 * Image column: thumbnail + filename + URL with copy button.
76 */
77 protected function column_image($item): string {
78 $thumb = wp_get_attachment_image($item->ID, [50, 50], true, ['style' => 'border-radius:4px;']);
79 $url = wp_get_attachment_url($item->ID);
80 $file = basename(get_attached_file($item->ID) ?: '');
81
82 return sprintf(
83 '<div class="metasync-image-cell">
84 <div class="metasync-image-thumb">%s</div>
85 <div class="metasync-image-info">
86 <strong class="metasync-image-filename">%s</strong>
87 <span class="metasync-image-url">
88 <a href="%s" target="_blank" title="%s">%s</a>
89 <button type="button" class="metasync-copy-btn" data-url="%s" title="%s">
90 <span class="dashicons dashicons-clipboard"></span>
91 </button>
92 </span>
93 </div>
94 </div>',
95 $thumb,
96 esc_html($file),
97 esc_url($url),
98 esc_attr($url),
99 esc_html($this->truncate_url($url)),
100 esc_attr($url),
101 esc_attr__('Copy URL', 'metasync')
102 );
103 }
104
105 /**
106 * Post column: linked posts or "Unattached".
107 *
108 * Shows all posts that use this attachment (via post_parent, featured image, or content embedding).
109 */
110 protected function column_post($item): string {
111 $post_ids = $this->attachment_posts[$item->ID] ?? [];
112
113 if (empty($post_ids)) {
114 return '<span class="metasync-text-muted">' . esc_html__('Unattached', 'metasync') . '</span>';
115 }
116
117 $links = [];
118 foreach ($post_ids as $post_id) {
119 $post = get_post($post_id);
120 if (!$post) {
121 continue;
122 }
123
124 $permalink = get_permalink($post->ID);
125 $title = $post->post_title ?: __('(no title)', 'metasync');
126
127 $links[] = sprintf(
128 '<div class="metasync-post-link-row">
129 <a href="%s" target="_blank" title="%s">%s</a>
130 <button type="button" class="metasync-copy-btn" data-url="%s" title="%s">
131 <span class="dashicons dashicons-clipboard"></span>
132 </button>
133 </div>',
134 esc_url($permalink),
135 esc_attr($title),
136 esc_html($this->truncate_text($title, 40)),
137 esc_attr($permalink),
138 esc_attr__('Copy post URL', 'metasync')
139 );
140 }
141
142 if (empty($links)) {
143 return '<span class="metasync-text-muted">' . esc_html__('Unattached', 'metasync') . '</span>';
144 }
145
146 return implode('', $links);
147 }
148
149 /**
150 * Status column: optimized badge with savings or unoptimized.
151 */
152 protected function column_status($item): string {
153 return self::render_status_html($item->ID);
154 }
155
156 /**
157 * Render status HTML for a given attachment.
158 * Reusable by AJAX handlers (DRY).
159 */
160 public static function render_status_html(int $attachment_id): string {
161 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
162
163 if (!$format) {
164 $file = get_attached_file($attachment_id);
165 $size = ($file && file_exists($file)) ? size_format((int) filesize($file), 1) : '';
166 $size_html = $size ? sprintf(
167 '<div class="metasync-size-info"><span class="metasync-size-detail">%s</span></div>',
168 esc_html($size)
169 ) : '';
170
171 return sprintf(
172 '<span class="metasync-status-badge metasync-status-unoptimized">%s</span>%s',
173 esc_html__('Unoptimized', 'metasync'),
174 $size_html
175 );
176 }
177
178 $badge = sprintf(
179 '<span class="metasync-status-badge metasync-status-optimized">%s (%s)</span>',
180 esc_html__('Optimized', 'metasync'),
181 esc_html(strtoupper($format))
182 );
183
184 $savings = self::get_size_savings($attachment_id);
185 if (!$savings) {
186 return $badge;
187 }
188
189 return $badge . sprintf(
190 '<div class="metasync-size-savings">'
191 . '<span class="metasync-savings-pct">&darr; %s%%</span>'
192 . '<span class="metasync-savings-detail">%s &rarr; %s</span>'
193 . '</div>',
194 esc_html($savings['percentage']),
195 esc_html($savings['original_human']),
196 esc_html($savings['converted_human'])
197 );
198 }
199
200 /**
201 * Calculate size savings for an optimized attachment.
202 */
203 private static function get_size_savings(int $attachment_id): ?array { // phpcs:ignore WordPress.NamingConventions
204 $file = get_attached_file($attachment_id);
205 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
206
207 if (!$file || !$format) {
208 return null;
209 }
210
211 // Original size: prefer stored meta, fall back to reading original file from disk
212 $original_size = (int) get_post_meta($attachment_id, '_metasync_original_filesize', true);
213 if (!$original_size && file_exists($file)) {
214 $original_size = (int) filesize($file);
215 // Backfill meta for future lookups
216 if ($original_size) {
217 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
218 }
219 }
220
221 if (!$original_size) {
222 return null;
223 }
224
225 $ext = $format === 'avif' ? '.avif' : '.webp';
226 $converted_path = preg_replace('/\.(jpe?g|png)$/i', $ext, $file);
227 $converted_size = ($converted_path && file_exists($converted_path)) ? (int) filesize($converted_path) : 0;
228
229 if (!$converted_size) {
230 return null;
231 }
232
233 return [
234 'percentage' => max(0, (int) round((($original_size - $converted_size) / $original_size) * 100)),
235 'original_human' => size_format($original_size, 1),
236 'converted_human' => size_format($converted_size, 1),
237 ];
238 }
239
240 /**
241 * Actions column: optimize or revert button.
242 */
243 protected function column_actions($item): string {
244 $format = get_post_meta($item->ID, '_metasync_converted_format', true);
245
246 if ($format) {
247 return sprintf(
248 '<button type="button" class="button button-small metasync-revert-btn" data-id="%d">
249 <span class="dashicons dashicons-undo" style="margin-top:3px;"></span> %s
250 </button>',
251 $item->ID,
252 esc_html__('Revert', 'metasync')
253 );
254 }
255
256 return sprintf(
257 '<button type="button" class="button button-small button-primary metasync-optimize-btn" data-id="%d">
258 <span class="dashicons dashicons-performance" style="margin-top:3px;"></span> %s
259 </button>',
260 $item->ID,
261 esc_html__('Optimize', 'metasync')
262 );
263 }
264
265 /**
266 * Default column handler.
267 */
268 protected function column_default($item, $column_name): string {
269 return '';
270 }
271
272 /**
273 * Bulk actions dropdown.
274 */
275 protected function get_bulk_actions(): array {
276 return [
277 'bulk_optimize' => __('Optimize Selected', 'metasync'),
278 ];
279 }
280
281 /**
282 * Extra controls: status filter dropdown.
283 */
284 protected function extra_tablenav($which): void {
285 if ($which !== 'top') {
286 return;
287 }
288
289 $current_status = isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '';
290 ?>
291 <div class="alignleft actions">
292 <select name="status_filter">
293 <option value=""><?php esc_html_e('All Images', 'metasync'); ?></option>
294 <option value="optimized" <?php selected($current_status, 'optimized'); ?>>
295 <?php esc_html_e('Optimized', 'metasync'); ?>
296 </option>
297 <option value="unoptimized" <?php selected($current_status, 'unoptimized'); ?>>
298 <?php esc_html_e('Unoptimized', 'metasync'); ?>
299 </option>
300 </select>
301 <?php submit_button(__('Filter', 'metasync'), '', 'filter_action', false); ?>
302 </div>
303 <?php
304 }
305
306 /**
307 * Prepare items for display.
308 */
309 public function prepare_items(): void {
310 $this->_column_headers = [
311 $this->get_columns(),
312 [],
313 $this->get_sortable_columns(),
314 ];
315
316 $current_page = $this->get_pagenum();
317 $filters = $this->get_filters();
318
319 $query_args = [
320 'post_type' => 'attachment',
321 'post_status' => 'inherit',
322 'post_mime_type' => ['image/jpeg', 'image/png'],
323 'posts_per_page' => self::PER_PAGE,
324 'paged' => $current_page,
325 'orderby' => $filters['orderby'],
326 'order' => $filters['order'],
327 ];
328
329 if (!empty($filters['search'])) {
330 $query_args['s'] = $filters['search'];
331 }
332
333 if ($filters['status'] === 'optimized') {
334 $query_args['meta_query'] = [
335 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
336 ];
337 } elseif ($filters['status'] === 'unoptimized') {
338 $query_args['meta_query'] = [
339 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
340 ];
341 }
342
343 $query = new WP_Query($query_args);
344
345 $this->items = $query->posts;
346 $this->resolve_attachment_posts();
347
348 $this->set_pagination_args([
349 'total_items' => $query->found_posts,
350 'per_page' => self::PER_PAGE,
351 'total_pages' => $query->max_num_pages,
352 ]);
353 }
354
355 /**
356 * Override get_pagenum to use custom paged param.
357 */
358 public function get_pagenum(): int {
359 $pagenum = isset($_REQUEST[self::PAGED_PARAM]) ? absint($_REQUEST[self::PAGED_PARAM]) : 0;
360
361 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
362 $pagenum = $this->_pagination_args['total_pages'];
363 }
364
365 return max(1, $pagenum);
366 }
367
368 /**
369 * Override pagination to use custom paged param and preserve tab.
370 */
371 protected function pagination($which): void {
372 if (empty($this->_pagination_args)) {
373 return;
374 }
375
376 $total_items = (int) $this->_pagination_args['total_items'];
377 $total_pages = (int) $this->_pagination_args['total_pages'];
378 $current = $this->get_pagenum();
379
380 if ($total_pages <= 1) {
381 return;
382 }
383
384 $output = '<span class="displaying-num">' . sprintf(
385 _n('%s item', '%s items', $total_items, 'metasync'),
386 number_format_i18n($total_items)
387 ) . '</span>';
388
389 $removable_args = [
390 'paged', 'paged_redir', 'paged_404',
391 '_wpnonce', '_wp_http_referer',
392 'action', 'action2',
393 ];
394
395 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
396 $current_url = remove_query_arg($removable_args, $current_url);
397 $current_url = add_query_arg('tab', 'image-library', $current_url);
398
399 // First page
400 if ($current === 1) {
401 $first_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
402 } else {
403 $first_link = sprintf(
404 '<a class="first-page button" href="%s"><span aria-hidden="true">%s</span></a>',
405 esc_url(add_query_arg(self::PAGED_PARAM, 1, $current_url)),
406 '&laquo;'
407 );
408 }
409
410 // Prev page
411 if ($current === 1) {
412 $prev_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
413 } else {
414 $prev_link = sprintf(
415 '<a class="prev-page button" href="%s"><span aria-hidden="true">%s</span></a>',
416 esc_url(add_query_arg(self::PAGED_PARAM, max(1, $current - 1), $current_url)),
417 '&lsaquo;'
418 );
419 }
420
421 // Page input
422 $html_current_page = sprintf(
423 '<input class="current-page" id="current-page-selector" type="text" name="%s" value="%s" size="%d" aria-describedby="table-paging" />',
424 self::PAGED_PARAM,
425 $current,
426 strlen($total_pages)
427 );
428
429 $html_total_pages = sprintf('<span class="total-pages">%s</span>', number_format_i18n($total_pages));
430
431 // Next page
432 if ($current >= $total_pages) {
433 $next_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
434 } else {
435 $next_link = sprintf(
436 '<a class="next-page button" href="%s"><span aria-hidden="true">%s</span></a>',
437 esc_url(add_query_arg(self::PAGED_PARAM, min($total_pages, $current + 1), $current_url)),
438 '&rsaquo;'
439 );
440 }
441
442 // Last page
443 if ($current >= $total_pages) {
444 $last_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
445 } else {
446 $last_link = sprintf(
447 '<a class="last-page button" href="%s"><span aria-hidden="true">%s</span></a>',
448 esc_url(add_query_arg(self::PAGED_PARAM, $total_pages, $current_url)),
449 '&raquo;'
450 );
451 }
452
453 $pagination_links_class = 'pagination-links';
454 $output .= "\n<span class='$pagination_links_class'>" .
455 $first_link . $prev_link .
456 '<span class="paging-input">' .
457 sprintf(
458 _x('%1$s of %2$s', 'paging', 'metasync'),
459 $html_current_page,
460 $html_total_pages
461 ) .
462 '</span>' .
463 $next_link . $last_link .
464 '</span>';
465
466 echo "<div class='tablenav-pages'>$output</div>";
467 }
468
469 /**
470 * Override print_column_headers for custom sort params.
471 */
472 public function print_column_headers($with_id = true): void {
473 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
474
475 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
476 $current_url = remove_query_arg(['paged', self::PAGED_PARAM], $current_url);
477
478 $current_orderby = isset($_GET[self::ORDERBY_PARAM]) ? sanitize_text_field($_GET[self::ORDERBY_PARAM]) : '';
479 $current_order = isset($_GET[self::ORDER_PARAM]) ? sanitize_text_field($_GET[self::ORDER_PARAM]) : 'asc';
480
481 foreach ($columns as $column_key => $column_display_name) {
482 $class = ['manage-column', "column-$column_key"];
483
484 if (in_array($column_key, $hidden, true)) {
485 $class[] = 'hidden';
486 }
487
488 if ($column_key === 'cb') {
489 $class[] = 'check-column';
490 }
491
492 $attr = '';
493
494 if (isset($sortable[$column_key])) {
495 list($orderby, $desc_first) = $sortable[$column_key];
496
497 if ($current_orderby === $orderby) {
498 $order = ($current_order === 'asc') ? 'desc' : 'asc';
499 $class[] = 'sorted';
500 $class[] = $current_order;
501 } else {
502 $order = $desc_first ? 'desc' : 'asc';
503 $class[] = 'sortable';
504 $class[] = $desc_first ? 'asc' : 'desc';
505 }
506
507 $sort_url = add_query_arg([
508 self::ORDERBY_PARAM => $orderby,
509 self::ORDER_PARAM => $order,
510 ], $current_url);
511
512 $column_display_name = sprintf(
513 '<a href="%s"><span>%s</span><span class="sorting-indicators"><span class="sorting-indicator asc" aria-hidden="true"></span><span class="sorting-indicator desc" aria-hidden="true"></span></span></a>',
514 esc_url($sort_url),
515 $column_display_name
516 );
517 }
518
519 $tag = ($column_key === 'cb') ? 'td' : 'th';
520 $id = $with_id ? "id='$column_key'" : '';
521
522 echo "<$tag $id class='" . implode(' ', $class) . "' $attr>$column_display_name</$tag>";
523 }
524 }
525
526 /**
527 * Batch-resolve all post relationships for the current page of attachments.
528 *
529 * Checks three sources:
530 * 1. post_parent (direct attachment parent)
531 * 2. _thumbnail_id post meta (featured image)
532 * 3. Post content references (wp-image-{ID} class used by WordPress editor)
533 */
534 private function resolve_attachment_posts(): void {
535 $this->attachment_posts = [];
536
537 if (empty($this->items)) {
538 return;
539 }
540
541 global $wpdb;
542
543 $ids = wp_list_pluck($this->items, 'ID');
544 $id_set = implode(',', array_map('intval', $ids));
545 $valid_statuses = "'publish','draft','private','pending','future'";
546
547 // 1. post_parent relationships
548 foreach ($this->items as $item) {
549 if (!empty($item->post_parent)) {
550 $parent = get_post($item->post_parent);
551 if ($parent && $parent->post_type !== 'attachment') {
552 $this->attachment_posts[$item->ID][] = (int) $item->post_parent;
553 }
554 }
555 }
556
557 // 2. Featured image relationships (_thumbnail_id meta)
558 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
559 $featured_rows = $wpdb->get_results(
560 "SELECT CAST(pm.meta_value AS UNSIGNED) AS attachment_id, pm.post_id
561 FROM {$wpdb->postmeta} pm
562 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
563 WHERE pm.meta_key = '_thumbnail_id'
564 AND pm.meta_value IN ({$id_set})
565 AND p.post_status IN ({$valid_statuses})
566 AND p.post_type NOT IN ('attachment','revision')"
567 );
568
569 foreach ($featured_rows as $row) {
570 $att_id = (int) $row->attachment_id;
571 $post_id = (int) $row->post_id;
572 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
573 $this->attachment_posts[$att_id][] = $post_id;
574 }
575 }
576
577 // 3. Content references: wp-image-{ID} class AND image filename in URLs
578 // Build a lookup of search tokens per attachment ID.
579 $search_map = []; // attachment_id => ['wp-image-123', 'filename.jpg']
580 $like_clauses = [];
581
582 foreach ($this->items as $item) {
583 $id = (int) $item->ID;
584 $tokens = [];
585
586 // wp-image-{ID} class (WordPress editor / Gutenberg blocks)
587 $tokens[] = 'wp-image-' . $id;
588
589 // Image filename from the attachment file path
590 $file = get_attached_file($id);
591 if ($file) {
592 $basename = basename($file);
593 if ($basename) {
594 $tokens[] = $basename;
595 }
596 }
597
598 $search_map[$id] = $tokens;
599
600 foreach ($tokens as $token) {
601 $escaped = $wpdb->esc_like($token);
602 $like_clauses[] = $wpdb->prepare('p.post_content LIKE %s', '%' . $escaped . '%');
603 }
604 }
605
606 if (!empty($like_clauses)) {
607 // Deduplicate clauses to avoid redundant OR conditions
608 $like_clauses = array_unique($like_clauses);
609 $like_sql = implode(' OR ', $like_clauses);
610
611 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
612 $content_rows = $wpdb->get_results(
613 "SELECT p.ID AS post_id, p.post_content
614 FROM {$wpdb->posts} p
615 WHERE ({$like_sql})
616 AND p.post_type NOT IN ('attachment','revision')
617 AND p.post_status IN ({$valid_statuses})"
618 );
619
620 foreach ($content_rows as $row) {
621 $post_id = (int) $row->post_id;
622 foreach ($search_map as $att_id => $tokens) {
623 foreach ($tokens as $token) {
624 if (strpos($row->post_content, $token) !== false) {
625 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
626 $this->attachment_posts[$att_id][] = $post_id;
627 }
628 break; // One match is enough for this attachment+post pair
629 }
630 }
631 }
632 }
633 }
634 }
635
636 /**
637 * Get sanitized filter values from request.
638 */
639 private function get_filters(): array {
640 return [
641 'search' => isset($_REQUEST['s']) ? sanitize_text_field($_REQUEST['s']) : '',
642 'status' => isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '',
643 'orderby' => isset($_REQUEST[self::ORDERBY_PARAM]) ? sanitize_sql_orderby($_REQUEST[self::ORDERBY_PARAM]) ?: 'date' : 'date',
644 'order' => isset($_REQUEST[self::ORDER_PARAM]) && strtolower($_REQUEST[self::ORDER_PARAM]) === 'asc' ? 'ASC' : 'DESC',
645 ];
646 }
647
648 /**
649 * Get image optimization statistics.
650 */
651 public static function get_stats(): array {
652 $total = (new WP_Query([
653 'post_type' => 'attachment',
654 'post_status' => 'inherit',
655 'post_mime_type' => ['image/jpeg', 'image/png'],
656 'posts_per_page' => -1,
657 'fields' => 'ids',
658 ]))->found_posts;
659
660 $optimized = (new WP_Query([
661 'post_type' => 'attachment',
662 'post_status' => 'inherit',
663 'post_mime_type' => ['image/jpeg', 'image/png'],
664 'posts_per_page' => -1,
665 'fields' => 'ids',
666 'meta_query' => [
667 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
668 ],
669 ]))->found_posts;
670
671 return [
672 'total' => $total,
673 'optimized' => $optimized,
674 'unoptimized' => $total - $optimized,
675 'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
676 ];
677 }
678
679 /**
680 * Truncate a URL for display.
681 */
682 private function truncate_url(string $url, int $max = 50): string {
683 if (strlen($url) <= $max) {
684 return $url;
685 }
686 return substr($url, 0, $max - 3) . '...';
687 }
688
689 /**
690 * Truncate text for display.
691 */
692 private function truncate_text(string $text, int $max = 40): string {
693 if (strlen($text) <= $max) {
694 return $text;
695 }
696 return substr($text, 0, $max - 3) . '...';
697 }
698 }
699