PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.11.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.11.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / images / duplicate-scanner.php

duplicate-scanner.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.11.0, at includes/classes/images/duplicate-scanner.php

246 lines 6.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\Images;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * Find the same image uploaded more than once.
10 *
11 * Media libraries accumulate copies: the same photo dragged in twice, a logo
12 * re-uploaded because nobody could find the first one, a demo import bringing
13 * its own version of files that already existed. WordPress does not notice — it
14 * appends `-1` to the filename and stores another full set of thumbnails.
15 *
16 * ## Identified by content, not by name
17 *
18 * Two files with the same bytes are the same image whatever they are called;
19 * two files called `logo.png` and `logo-1.png` may be entirely different. So
20 * grouping is by a hash of the file itself.
21 *
22 * The hash is taken from the **stored original** where one exists, not the file
23 * currently on disk. Compression rewrites bytes, so hashing the live file would
24 * make two copies of one photo stop matching the moment one of them was
25 * optimized — the tool would quietly go blind exactly on the libraries that
26 * have been tidied up.
27 *
28 * Hashes are cached in post meta, so a rescan only reads files it has not seen.
29 */
30 class DuplicateScanner {
31
32 const HASH_META = '_ablocks_file_hash';
33
34 /**
35 * Attachments examined per batch.
36 */
37 const BATCH = 40;
38
39 /**
40 * Group images by content.
41 *
42 * @param int $limit Attachments to examine.
43 * @param int $offset Where to resume.
44 * @return array{groups:array, scanned:int, total:int, done:bool}
45 */
46 public static function scan( $limit = self::BATCH, $offset = 0 ) {
47 $limit = max( 1, min( 200, (int) $limit ) );
48 $offset = max( 0, (int) $offset );
49
50 $query = new \WP_Query(
51 [
52 'post_type' => 'attachment',
53 'post_status' => 'inherit',
54 'post_mime_type' => 'image',
55 'posts_per_page' => $limit,
56 'offset' => $offset,
57 'orderby' => 'ID',
58 'order' => 'ASC',
59 'fields' => 'ids',
60 ]
61 );
62
63 foreach ( $query->posts as $id ) {
64 self::hash_for( (int) $id );
65 }
66
67 return [
68 'groups' => [],
69 'scanned' => $offset + count( $query->posts ),
70 'total' => (int) $query->found_posts,
71 'done' => count( $query->posts ) < $limit,
72 ];
73 }
74
75 /**
76 * Every set of attachments sharing identical content.
77 *
78 * Read from the stored hashes, so this is a database query rather than a
79 * pass over the filesystem — the reading happens during {@see self::scan()}.
80 *
81 * @return array Groups, largest first.
82 */
83 public static function groups() {
84 global $wpdb;
85
86 $rows = $wpdb->get_results(
87 $wpdb->prepare(
88 "SELECT post_id, meta_value AS hash
89 FROM {$wpdb->postmeta}
90 WHERE meta_key = %s AND meta_value <> ''",
91 self::HASH_META
92 ),
93 ARRAY_A
94 );
95
96 $by_hash = [];
97 foreach ( (array) $rows as $row ) {
98 $by_hash[ $row['hash'] ][] = (int) $row['post_id'];
99 }
100
101 $groups = [];
102 foreach ( $by_hash as $hash => $ids ) {
103 if ( count( $ids ) < 2 ) {
104 continue;
105 }
106
107 sort( $ids );
108 $members = [];
109 $wasted = 0;
110
111 foreach ( $ids as $index => $id ) {
112 if ( 'attachment' !== get_post_type( $id ) ) {
113 continue;
114 }
115
116 $used = UnusedScanner::find_usage( $id );
117 $size = UnusedScanner::size_of( $id );
118
119 // Everything after the first copy is wasted space, but only the
120 // unreferenced ones can actually be removed.
121 if ( $index > 0 && empty( $used ) ) {
122 $wasted += $size;
123 }
124
125 $members[] = [
126 'id' => $id,
127 'title' => get_the_title( $id ),
128 'thumb' => wp_get_attachment_image_url( $id, 'thumbnail' ),
129 'url' => wp_get_attachment_url( $id ),
130 'date' => get_the_date( 'Y-m-d', $id ),
131 'bytes' => $size,
132 'size' => size_format( $size, 1 ),
133 'used' => ! empty( $used ),
134 'why' => ! empty( $used ) ? (string) reset( $used ) : '',
135 ];
136 }
137
138 if ( count( $members ) < 2 ) {
139 continue;
140 }
141
142 // The oldest copy that is actually referenced is the one to keep;
143 // failing that, simply the oldest. Suggesting deletion of the copy a
144 // page is pointing at would be exactly wrong.
145 $keep = $members[0]['id'];
146 foreach ( $members as $member ) {
147 if ( $member['used'] ) {
148 $keep = $member['id'];
149 break;
150 }
151 }
152
153 $groups[] = [
154 'hash' => (string) $hash,
155 'members' => $members,
156 'keep' => $keep,
157 'wasted' => $wasted,
158 'saving' => size_format( $wasted, 1 ),
159 ];
160 }//end foreach
161
162 usort(
163 $groups,
164 function ( $a, $b ) {
165 return $b['wasted'] <=> $a['wasted'];
166 }
167 );
168
169 return $groups;
170 }
171
172 /**
173 * Content hash for an attachment, computed once and remembered.
174 *
175 * @param int $attachment_id Attachment ID.
176 * @param bool $force Recompute even if a hash is stored.
177 * @return string
178 */
179 public static function hash_for( $attachment_id, $force = false ) {
180 $attachment_id = (int) $attachment_id;
181
182 if ( ! $force ) {
183 $stored = get_post_meta( $attachment_id, self::HASH_META, true );
184 if ( is_string( $stored ) && '' !== $stored ) {
185 return $stored;
186 }
187 }
188
189 $file = get_attached_file( $attachment_id );
190 if ( ! $file ) {
191 return '';
192 }
193
194 // Prefer the untouched copy: compression changes the bytes, and identity
195 // has to survive that or two copies of one photo stop matching as soon
196 // as either is optimized.
197 $backup = Compressor::backup_path( $file, $attachment_id );
198 $source = ( $backup && file_exists( $backup ) ) ? $backup : $file;
199
200 if ( ! file_exists( $source ) ) {
201 return '';
202 }
203
204 $hash = sha1_file( $source );
205 if ( false === $hash ) {
206 return '';
207 }
208
209 update_post_meta( $attachment_id, self::HASH_META, $hash );
210
211 return $hash;
212 }
213
214 /**
215 * Summary for the panel header.
216 *
217 * @return array{groups:int, extra:int, bytes:int, size:string}
218 */
219 public static function summary() {
220 $groups = self::groups();
221 $extra = 0;
222 $bytes = 0;
223
224 foreach ( $groups as $group ) {
225 $extra += count( $group['members'] ) - 1;
226 $bytes += (int) $group['wasted'];
227 }
228
229 return [
230 'groups' => count( $groups ),
231 'extra' => $extra,
232 'bytes' => $bytes,
233 'size' => size_format( $bytes, 1 ),
234 ];
235 }
236
237 /**
238 * Drop a stored hash when the file changes, so it is recomputed.
239 *
240 * @param int $attachment_id Attachment ID.
241 */
242 public static function forget( $attachment_id ) {
243 delete_post_meta( (int) $attachment_id, self::HASH_META );
244 }
245 }
246