PluginProbe
Image Source Control Lite – Show Image Credits and Captions / 3.11.0
Image Source Control Lite – Show Image Credits and Captions v3.11.0
3.12.0 3.11.0 trunk 1.1 1.1.1 1.1.2 1.1.2.1 1.1.3 1.10 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.2 1.2.0.1 1.2.0.2 1.2.0.3 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.4.1 1.3.5 All 110 releases
image-source-control-isc / includes / model.php

model.php in Image Source Control Lite – Show Image Credits and Captions 3.11.0, at includes/model.php

685 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use ISC\Image_Sources\Image_Sources;
4 use ISC\Image_Sources\Post_Meta;
5 use ISC\indexer;
6
7 /**
8 * Logic to get and store sources
9 */
10 class ISC_Model {
11 /**
12 * Instance of ISC_Model
13 *
14 * @var ISC_Model
15 */
16 protected static $instance;
17
18 /**
19 * Maximum number of entries in custom queries
20 */
21 const MAX_POSTS = 100;
22
23 /**
24 * Setup registers filters and actions.
25 */
26 public function __construct() {
27 add_filter( 'attachment_fields_to_save', [ $this, 'isc_fields_save' ], 10, 2 );
28 }
29
30 /**
31 * Get instance of ISC_Model
32 *
33 * @return ISC_Model
34 */
35 public static function get_instance() {
36 return self::$instance;
37 }
38
39 /**
40 * Update the isc_image_posts and isc_post_images indexes
41 *
42 * @depecated since October 2024
43 *
44 * @param integer $post_id ID of the target post.
45 * @param string $content content of the target post.
46 */
47 public static function update_indexes( $post_id, $content ) {
48
49 ISC_Log::log( 'function removed. Use \ISC\Indexer::update_indexes' );
50
51 ISC\Indexer::update_indexes( $content );
52 }
53
54 /**
55 * Update isc_image_posts meta field with includes IDs of all posts that have the image in its content
56 * the function should be used to push a post ID to the (maybe) existing meta field
57 *
58 * @removed since April 2025
59 *
60 * @param integer $post_id ID of the target post.
61 * @param array $image_ids IDs of the attachments in the content.
62 */
63 public static function update_image_posts_meta( $post_id, $image_ids ) {
64
65 ISC_Log::log( 'function removed. \ISC\Indexer::update_indexes covers most of it now' );
66 }
67
68 /**
69 * Save image source to post_meta
70 * Used as a filter function. See save_field() it you are looking for a direct method to store post meta values
71 *
72 * @updated 1.5 added field for url
73 *
74 * @param array $post post data.
75 * @param array $attachment attachment data.
76 * @return array $post updated post data
77 */
78 public function isc_fields_save( $post, $attachment ) {
79 if ( isset( $attachment['isc_image_source'] ) ) {
80 self::save_field( $post['ID'], 'isc_image_source', Image_Sources::sanitize_source_html( $attachment['isc_image_source'] ) );
81 }
82 if ( isset( $attachment['isc_image_source_url'] ) ) {
83 self::save_field( $post['ID'], 'isc_image_source_url', $this->sanitize_source_url( $attachment['isc_image_source_url'] ) );
84 }
85 $own = ( isset( $attachment['isc_image_source_own'] ) ) ? $attachment['isc_image_source_own'] : '';
86 self::save_field( $post['ID'], 'isc_image_source_own', $own );
87
88 if ( isset( $attachment['isc_image_licence'] ) ) {
89 self::save_field( $post['ID'], 'isc_image_licence', sanitize_text_field( $attachment['isc_image_licence'] ) );
90 }
91
92 return $post;
93 }
94
95 /**
96 * Sanitize source URL string by removing any HTML tags
97 *
98 * @param string $source_url source URL string.
99 *
100 * @return string sanitized source URL
101 */
102 public function sanitize_source_url( string $source_url ): string {
103 return wp_kses( $source_url, [] );
104 }
105
106 /**
107 * Store attachment-related post meta values
108 *
109 * @param int $att_id WP_Post ID of the attachment.
110 * @param string $key post meta key.
111 * @param mixed $value post meta value.
112 */
113 public static function save_field( $att_id, $key, $value ) {
114 if ( is_string( $value ) ) {
115 $value = trim( $value );
116 }
117
118 self::update_post_meta( $att_id, $key, $value );
119 }
120
121 /**
122 * Update image-post index attached to attachments when a post is updated
123 *
124 * @removed since April 2025
125 *
126 * @param int $post_ID Post ID.
127 * @param WP_Post $post_after Post object following the update.
128 * @param WP_Post $post_before Post object before the update.
129 *
130 * @return void
131 */
132 public static function update_image_post_meta( $post_ID, $post_after, $post_before ) {
133 ISC_Log::log( 'function removed without a replacement.' );
134 }
135
136 /**
137 * Remove the post_images index from a single post
138 * namely the post meta field `isc_post_images`
139 *
140 * @moved since April 2025
141 *
142 * @param int $post_id Post ID.
143 */
144 public static function clear_single_post_images_index( $post_id ) {
145 ISC_Log::log( 'function moved. \ISC\Image_Sources\Post_Meta\Post_Images_Meta::delete' );
146
147 Post_Meta\Post_Images_Meta::delete( $post_id );
148 }
149
150 /**
151 * Remove post_images index
152 * namely the post meta field `isc_post_images`
153 *
154 * @moved since April 2025
155 *
156 * @return int|false The number of rows updated, or false on error.
157 */
158 public static function clear_post_images_index() {
159 ISC_Log::log( 'function moved. \ISC\Image_Sources\Post_Images_Meta::delete_all' );
160
161 return Post_Meta\Post_Images_Meta::delete_all();
162 }
163
164 /**
165 * Remove image_posts index
166 * namely the post meta field `isc_image_posts`
167 *
168 * @moved since April 2025
169 *
170 * @return int|false The number of rows updated, or false on error.
171 */
172 public static function clear_image_posts_index() {
173 ISC_Log::log( 'function moved. \ISC\Image_Sources\Image_Posts_Meta::delete_all' );
174
175 return Post_Meta\Image_Posts_Meta::delete_all();
176 }
177
178 /**
179 * Remove all image-post relations
180 * this concerns the post meta fields `isc_image_posts` and `isc_post_images`
181 *
182 * @deprecated since April 2025
183 *
184 * @return bool True if the option was removed
185 */
186 public static function clear_index(): bool {
187 ISC_Log::log( 'function moved. \ISC\Indexer::clear_index' );
188
189 return Indexer::clear_index();
190 }
191
192 /**
193 * Checks if there are image with missing sources
194 * this includes attachments that were not indexed yet (don’t have the appropriate meta values)
195 *
196 * @return int number of images with missing sources
197 */
198 public static function count_missing_sources(): int {
199
200 // get known and used attachments without sources
201 return count( self::get_attachments_with_empty_sources() );
202 }
203
204 /**
205 * Get attachments.
206 * Allows to be called with custom arguments.
207 *
208 * @param array $args arguments for the query.
209 * @return array with attachments. Returns all attachments in the Media Library if called without additional arguments.
210 */
211 public static function get_attachments( $args ) {
212 $args = wp_parse_args(
213 $args,
214 [
215 'post_type' => 'attachment',
216 'numberposts' => -1,
217 'post_status' => null,
218 'post_parent' => null,
219 ]
220 );
221
222 return get_posts( $args );
223 }
224
225 /**
226 * Get all attachments with empty sources options.
227 *
228 * @return array with attachments.
229 */
230 public static function get_attachments_with_empty_sources() {
231 global $wpdb;
232
233 $where_clause = "wp_posts.post_type = 'attachment' AND wp_posts.post_status = 'inherit'";
234
235 // Add image type check if images_only is enabled
236 if ( \ISC\Media_Type_Checker::enabled_images_only_option() ) {
237 $where_clause .= " AND wp_posts.post_mime_type LIKE 'image/%'";
238 }
239
240 /**
241 * Filters the WHERE clause for the query that finds attachments with empty sources.
242 *
243 * @param string $where_clause The WHERE clause conditions for the attachments query.
244 * @return string The filtered WHERE clause.
245 */
246 $where_clause = apply_filters( 'isc_image_sources_attachments_with_empty_sources_where_clause', $where_clause );
247
248 /**
249 * Using EXISTS instead of LEFT JOINs resulted in much faster queries and helped caching the results.
250 */
251 $query = "SELECT wp_posts.ID, wp_posts.post_title, wp_posts.post_parent
252 FROM {$wpdb->posts} AS wp_posts
253 WHERE {$where_clause}
254 AND NOT EXISTS (
255 SELECT 1
256 FROM {$wpdb->postmeta} AS mt1
257 WHERE mt1.post_id = wp_posts.ID
258 AND mt1.meta_key = 'isc_image_source_own'
259 AND mt1.meta_value = '1'
260 )
261 AND (
262 EXISTS (
263 SELECT 1
264 FROM {$wpdb->postmeta} AS wp_postmeta
265 WHERE wp_postmeta.post_id = wp_posts.ID
266 AND wp_postmeta.meta_key = 'isc_image_source'
267 AND wp_postmeta.meta_value = ''
268 )
269 OR NOT EXISTS (
270 SELECT 1
271 FROM {$wpdb->postmeta} AS mt2
272 WHERE mt2.post_id = wp_posts.ID
273 AND mt2.meta_key = 'isc_image_source'
274 )
275 )
276 ORDER BY wp_posts.ID DESC
277 LIMIT %d, %d
278 ";
279
280 // The result of the query is already cached for a day, or until an image is added or removed.
281 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
282 $attachments = $wpdb->get_results( $wpdb->prepare( $query, 0, self::MAX_POSTS ) );
283
284 $count = count( $attachments );
285
286 // update the transient if the number of results differ
287 if ( $count !== (int) get_transient( 'isc-show-missing-sources-warning' ) ) {
288 set_transient( 'isc-show-missing-sources-warning', $count, DAY_IN_SECONDS );
289 }
290
291 return $attachments;
292 }
293
294 /**
295 * Update the transient we set for missing sources to not check them for another day
296 * running each time we are writing the `isc_image_source` post meta key
297 *
298 * @return int number of missing sources.
299 */
300 public static function update_missing_sources_transient() {
301 $missing_sources = self::count_missing_sources();
302 set_transient( 'isc-show-missing-sources-warning', $missing_sources, DAY_IN_SECONDS );
303 return $missing_sources;
304 }
305
306 /**
307 * Filter image ids from content
308 *
309 * @param string $content post content.
310 * @return string[] with image ids => image src uri-s
311 */
312 public static function filter_image_ids( $content = '' ) {
313 $srcs = [];
314
315 ISC_Log::log( 'enter looking for image IDs within the content' );
316
317 if ( empty( $content ) ) {
318 ISC_Log::log( 'exit due to missing content' );
319 return $srcs;
320 }
321
322 // parse HTML with DOM
323 $dom = new DOMDocument();
324
325 libxml_use_internal_errors( true );
326 if ( version_compare( PHP_VERSION, '8.2', '<' ) && function_exists( 'mb_convert_encoding' ) ) {
327 $content = mb_convert_encoding( $content, 'HTML-ENTITIES', 'UTF-8' );
328 }
329 $dom->loadHTML( $content );
330
331 // Prevents from sending E_WARNINGs notice (Outputs are forbidden during activation)
332 libxml_clear_errors();
333
334 /**
335 * Handle multiple tags at once
336 * the original use case is checking AMP pages generated in reader mode in the AMP plugin and in AMPforWP
337 * for IMG and AMP-IMG tags
338 */
339 $tags = apply_filters( 'isc_filter_image_ids_tags', [ 'img' ] );
340
341 if ( ! is_array( $tags ) ) {
342 return [];
343 }
344
345 // I am keeping the original $dom->getElementsByTagName as well as the new DOMXpath solution for multiple elements for now
346 // since I am not 100% sure about the implications of the latter on existing features
347 if ( count( $tags ) === 1 ) {
348 $nodes = $dom->getElementsByTagName( 'img' );
349 } else {
350 $xpath = new DOMXpath( $dom );
351 $tags_string = '//' . implode( '|//', $tags );
352 $nodes = $xpath->query( $tags_string );
353 }
354
355 $nodes_count = count( $nodes );
356
357 ISC_Log::log( sprintf( 'found %d img tags', $nodes_count ) );
358
359 if ( ISC_Log::is_type( 'content' ) ) {
360 ISC_Log::log( sprintf( 'looked in content: %s', $content ) );
361 }
362
363 foreach ( $nodes as $node ) {
364 if ( isset( $node->attributes ) ) {
365 $matched = false;
366 if ( $node->attributes->getNamedItem( 'class' ) !== null ) {
367 ISC_Log::log( sprintf( 'found class attribute "%s"', $node->attributes->getNamedItem( 'class' )->textContent ) );
368
369 if ( preg_match( '#.*wp-image-(\d+?).*#U', $node->attributes->getNamedItem( 'class' )->textContent, $matches ) ) {
370 // check if a src attribute exists
371 if ( $node->attributes->getNamedItem( 'src' ) !== null ) {
372 $srcs[ intval( $matches[1] ) ] = $node->attributes->getNamedItem( 'src' )->textContent;
373 $matched = true;
374
375 ISC_Log::log( sprintf( 'found image ID "%d" with src "%s"', intval( $matches[1] ), $srcs[ intval( $matches[1] ) ] ) );
376 } else {
377 ISC_Log::log( sprintf( 'no src attribute found for image ID "%d"', intval( $matches[1] ) ) );
378 }
379 }
380 }
381 if ( ! $matched ) {
382 if ( $node->attributes->getNamedItem( 'src' ) !== null ) {
383 $url = $node->attributes->getNamedItem( 'src' )->textContent;
384 ISC_Log::log( sprintf( 'found src "%s"', $url ) );
385 // get ID of images by url
386 $id = self::get_image_by_url( $url );
387 if ( $id ) {
388 $srcs[ $id ] = $url;
389 }
390 }
391 }
392 }
393 }
394
395 /**
396 * Filter image IDs found in HTML content, or add new ones based on other rules.
397 *
398 * @since 2.9.0
399 *
400 * @param string[] $srcs image sources with image ids => image src uri
401 * @param string $content any HTML document
402 */
403 return apply_filters( 'isc_filter_image_ids_from_content', $srcs, $content );
404 }
405
406 /**
407 * Get image ID by URL accessing the database directly
408 *
409 * @param string $url URL of the image.
410 *
411 * @return integer ID of the image.
412 */
413 public static function get_image_by_url( string $url = '' ): ?int {
414 global $wpdb;
415
416 ISC_Log::log( 'enter get_image_by_url() to look for URL ' . $url );
417
418 $original_url = $url;
419 $url = apply_filters( 'isc_filter_url_pre_get_image_by_url', $url );
420
421 // replace certain characters that WordPress accepts in file names only to test if the URL is valid
422 $url_to_check = esc_url( $url, [ 'http', 'https' ] );
423
424 if ( empty( $url ) || $url_to_check !== $url ) {
425 return 0;
426 }
427
428 // ignore src strings with more than 1000 characters; these could be base24 images not hosted in WordPress
429 if ( strlen( $url ) > 1000 ) {
430 ISC_Log::log( 'exit due to URL length' );
431 return 0;
432 }
433
434 // get the file extension, e.g. "jpg"
435 $ext = pathinfo( $url, PATHINFO_EXTENSION );
436 if ( ! $ext ) {
437 ISC_Log::log( 'exit get_image_by_url() no extension found' );
438 if ( apply_filters( 'isc_allow_empty_file_extension', __return_false() ) ) {
439 ISC_Log::log( "get_image_by_url() didn’t find an extension for $url but continues." );
440 } else {
441 return 0;
442 }
443 } elseif ( ! in_array( $ext, Image_Sources::get_allowed_extensions(), true ) ) {
444 // a valid image extension is required, if an extension is given
445 ISC_Log::log( 'exit get_image_by_url() due to invalid image extension' );
446 return 0;
447 }
448
449 /**
450 * Check for the format 'image-title-(e12452112-)300x200.jpg(?query…)' and removes
451 * - the image size
452 * - edit marks
453 * - "scaled" or "rotated"
454 * - additional query vars
455 */
456 // this is how WordPress core is detecting changed image URLs
457 $newurl = esc_url( preg_replace( "/-(?:\d+x\d+|scaled|rotated)\.{$ext}(.*)/i", '.' . $ext, $url ) );
458 $storage = new ISC_Storage_Model();
459
460 // check if the URL is already in storage and if so, take it from there
461 if ( $storage->is_image_url_in_storage( $newurl ) ) {
462 $id_from_storage = absint( $storage->get_image_id_from_storage( $newurl ) );
463 ISC_Log::log( "found $newurl in storage with attachment ID $id_from_storage" );
464 return $id_from_storage;
465 }
466
467 /**
468 * Attachment_url_to_postid needs the URL including protocol, but cannot handle sizes, so it needs to be at exactly this position
469 * this function finds images based on the _wp_attached_file post meta value that includes the image path followed after the upload dir
470 * it therefore also works when the domain changed
471 * since _wp_attached_file contains "scaled" or "rotated" as well, we keep them, but only remove sizes
472 */
473 $id = attachment_url_to_postid( esc_url( preg_replace( "/-\d+x\d+\.{$ext}(.*)/i", '.' . $ext, $url ) ) );
474 if ( $id ) {
475 // store attachment ID in storage
476 $storage->update_post_id( $newurl, $id );
477 ISC_Log::log( '_attachment_url_to_postid found image ID ' . $id );
478 return $id;
479 }
480
481 // remove protocol (http or https)
482 $url = str_ireplace( [ 'http:', 'https:' ], '', $url );
483 $newurl = str_ireplace( [ 'http:', 'https:' ], '', $newurl );
484
485 // gather different URLs formats
486 $urls = [
487 'http:' . $url,
488 'https:' . $url,
489 'http:' . $newurl,
490 'https:' . $newurl,
491 ];
492
493 // remove duplicates
494 $urls = array_unique( $urls );
495
496 $url_queries = [];
497 foreach ( $urls as $_url ) {
498 // return if any of the URLs is already in storage
499 if ( $storage->is_image_url_in_storage( $_url ) ) {
500 $id_from_storage = absint( $storage->get_image_id_from_storage( $_url ) );
501 ISC_Log::log( "found $newurl in storage with attachment ID $id_from_storage" );
502 return $id_from_storage;
503 }
504
505 $url_queries[] = 'guid = "' . esc_url( $_url ) . '"';
506 }
507 $url_query_string = implode( ' OR ', $url_queries );
508 ISC_Log::log( sprintf( 'SQL query looking for anything with %s', implode( ', ', array_unique( [ $url, $newurl ] ) ) ) );
509
510 // not escaped, because escaping already happened above
511 $raw_query = "SELECT ID, guid FROM `$wpdb->posts` WHERE post_type='attachment' AND {$url_query_string} LIMIT 1";
512
513 $query = apply_filters( 'isc_get_image_by_url_query', $raw_query, $newurl );
514 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
515 $results = $wpdb->get_results( $query );
516
517 $id = isset( $results[0]->ID ) ? absint( $results[0]->ID ) : 0;
518 $guid = $results[0]->guid ?? null;
519
520 /**
521 * Filter the image ID found by ISC
522 *
523 * Especially useful for compatibility with plugins that manipulate the image URL or meta data so that our checks don’t work
524 *
525 * @param int $id attachment ID or null
526 * @param array $params array with additional, optional parameters
527 */
528 $id = apply_filters(
529 'isc_filter_final_id_get_image_by_url',
530 $id,
531 [
532 'original_url' => $original_url,
533 'newurl' => $newurl,
534 'url' => $url,
535 ]
536 );
537
538 if ( $id ) {
539 // if no $guid is found, we store one of the earlier URLs
540 $storage->update_post_id( $guid ?? $url, $id );
541 ISC_Log::log( 'found image ID ' . $id );
542 } else {
543 // this should ideally only apply to image URLs that are not in the media library
544 // ISC also stores the URL to prevent too many database requests
545 // using $newurl, because it is already stripped by potential parameters and stuff
546 $storage->update( $newurl, [ 'post_id' => null ] );
547 ISC_Log::log( 'no image ID found' );
548 }
549
550 return $id;
551 }
552
553 /**
554 * Store post meta information.
555 * Use this function instead of core’s update_post_meta() to allow debugging
556 *
557 * @param int $post_id post ID of the attachment.
558 * @param string $key post meta key.
559 * @param mixed $value value of the post meta information.
560 *
561 * @return bool|int
562 */
563 public static function update_post_meta( int $post_id, string $key, $value ) {
564 /**
565 * Run when any post meta information is stored by ISC
566 *
567 * @since 2.9.0
568 *
569 * @param int $post_id post ID of the attachment.
570 * @param string $key post meta key.
571 * @param mixed $value value of the post meta information.
572 */
573 do_action( 'isc_update_post_meta', $post_id, $key, $value );
574
575 return update_post_meta( $post_id, $key, $value );
576 }
577
578 /**
579 * Get an array of all posts that have the isc_post_images meta field set
580 * and the number of them not having it
581 * list each post type separately
582 *
583 * @return array
584 */
585 public static function get_posts_with_image_index(): array {
586 $post_types = get_post_types( [ 'public' => true ] );
587 // remove the attachment post type
588 $post_types = array_diff( $post_types, [ 'attachment' ] );
589 $results = [];
590
591 foreach ( $post_types as $post_type ) {
592 $count_posts = wp_count_posts( $post_type );
593 $total_posts = $count_posts->publish;
594
595 $args = [
596 'post_type' => $post_type,
597 'posts_per_page' => self::MAX_POSTS,
598 'post_status' => 'publish',
599 'fields' => 'ids',
600 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
601 'meta_query' => [
602 [
603 'key' => 'isc_post_images',
604 'compare' => 'NOT EXISTS',
605 ],
606 ],
607 ];
608 $query_without = new WP_Query( $args );
609 $posts_without_meta_field = $query_without->post_count;
610
611 $results[ $post_type ] = [
612 'total_posts' => $total_posts,
613 'without_meta_field' => $posts_without_meta_field,
614 'with_meta_field' => $total_posts - $posts_without_meta_field,
615 ];
616 }
617
618 return $results;
619 }
620
621 /**
622 * Get the attachment’s file URL from the database
623 *
624 * @param int $image_id Attachment ID.
625 *
626 * @return string
627 */
628 public function get_base_file_url( int $image_id ): string {
629 // load the attachment post
630 $file = get_post_meta( $image_id, '_wp_attached_file', true );
631 // get guid as fallback, e.g., _wp_attached_file can be empty for external images
632 if ( empty( $file ) ) {
633 $attachment = get_post( $image_id );
634 $file = $attachment->guid;
635 }
636
637 return $file;
638 }
639
640 /**
641 * Remove all plugin meta from non-image attachments
642 *
643 * @return void
644 */
645 public static function remove_plugin_meta_from_non_images() {
646 // The meta keys used by ISC that should be removed from non-images
647 $isc_meta_keys = [
648 'isc_image_source',
649 'isc_image_source_url',
650 'isc_image_license',
651 'isc_image_source_own',
652 'isc_image_posts',
653 'isc_possible_usages',
654 'isc_possible_usages_last_check',
655 ];
656
657 /**
658 * Filter the meta keys to be removed from non-image attachments
659 *
660 * @param array $isc_meta_keys The meta keys to be removed from non-image attachments.
661 */
662 apply_filters( 'isc_cleanup_non_image_meta_keys', $isc_meta_keys );
663
664 $attachments = get_posts(
665 [
666 'post_type' => 'attachment',
667 'post_status' => 'inherit',
668 'posts_per_page' => -1,
669 ]
670 );
671
672 if ( empty( $attachments ) ) {
673 return;
674 }
675
676 foreach ( $attachments as $attachment ) {
677 if ( ! \ISC\Media_Type_Checker::is_image( $attachment ) ) {
678 foreach ( $isc_meta_keys as $meta_key ) {
679 delete_post_meta( $attachment->ID, $meta_key );
680 }
681 }
682 }
683 }
684 }
685