PluginProbe
Media Sync / trunk
Media Sync vtrunk
1.5.3 1.5.2 1.5.1 trunk 0.1.0 0.1.1 0.1.2 0.1.3 0.1.4 0.1.5 0.1.6 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.2 1.2.3 All 44 releases
media-sync / includes / MediaSync.class.php

MediaSync.class.php in Media Sync trunk, at includes/MediaSync.class.php

1,561 lines 68.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Media Sync
5 *
6 * This class is used for generating main content and also to import files to database
7 *
8 * @package MediaSync
9 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
10 * @since 0.1.0
11 * @author Erol Živina
12 */
13 if ( !class_exists( 'MediaSync' ) ) :
14
15 class MediaSync
16 {
17
18 /**
19 * Absolute path to main "uploads" directory.
20 *
21 * @var null
22 */
23 static $upload_dir_path = null;
24
25 /**
26 * Files already in Media Library.
27 *
28 * @var null|array
29 */
30 static $files_in_db = null;
31
32 /**
33 * For showing passed time when debugging.
34 *
35 * @since 1.2.0
36 * @var null|string
37 */
38 static $start_time = null;
39
40 /**
41 * Render main plugin content
42 *
43 * @since 0.1.0
44 * @return void
45 */
46 static public function media_sync_main_page()
47 {
48 // Suppress raw PHP errors on Media Sync page, so we can show cleaner custom message
49 @ini_set('display_errors', '0');
50
51 // Register shutdown function to catch fatal errors (e.g. memory exhausted)
52 register_shutdown_function(function() {
53 $message = self::media_sync_get_fatal_error_message();
54 if ( ! empty( $message ) ) {
55 self::media_sync_render_error_message( $message );
56 }
57 });
58
59 if(!MediaSync::media_sync_user_has_general_access()) {
60 wp_die(__('You do not have sufficient permissions to access this page.', 'media-sync'));
61 }
62
63 $scan_files = self::filter_input_boolean(INPUT_GET, 'scan_files');
64 $associated_filter = self::sanitize_input_string(INPUT_GET, 'associated-filter');
65 $missing_from_ml = $associated_filter && urldecode($associated_filter) === 'missing_from:media_library';
66
67 $here = esc_url(get_admin_url(null, 'upload.php?page=media-sync-page'));
68
69 $upload_dir_path = self::media_sync_get_uploads_basedir();
70 $relative_path = self::media_sync_get_relative_path($upload_dir_path);
71 $no_upload_dir_message = __('Scan directory not found. Please check settings.', 'media-sync');
72 ?>
73
74 <div class="wrap main-media-sync-page" xmlns="http://www.w3.org/1999/html" xmlns="http://www.w3.org/1999/html">
75 <h1><?= __('Media Sync', 'media-sync') ?></h1>
76
77 <?php do_action('media_sync_extended_initial_notice'); ?>
78
79 <div class="notice notice-error hidden media-sync-global-errors js-media-sync-error-placeholder"></div>
80
81 <?php if ($scan_files) : ?>
82 <div class="notice notice-error">
83 <p><?= __('Please backup your database! This plugin makes database changes.', 'media-sync') ?></p>
84 </div>
85 <div class="notice notice-success notice-files-imported">
86 <p><?= sprintf(__('Done! Highlighted files were successfully imported. %s to see changes.', 'media-sync'),
87 '<a href="'.add_query_arg('scan_files', 1, $here).'">'.__('Re-scan', 'media-sync').'</a>') ?></p>
88 </div>
89 <?php endif; ?>
90
91 <div class="media-sync-list-files">
92 <form action="<?= $here ?>" method="GET">
93 <input type="hidden" name="page" value="media-sync-page"/>
94 <input type="hidden" name="scan_files" value="<?= $scan_files ?>"/>
95 <div class="media-sync-buttons-holder">
96 <?php if (!$scan_files) : ?>
97 <div class="card">
98 <h2 class="title"><?= __('Sync - uploads directory', 'media-sync') ?></h2>
99
100 <?php if($upload_dir_path) : ?>
101 <a class="button button-primary" href="<?= add_query_arg('scan_files', 1, $here) ?>">
102 <?= __('Scan Files', 'media-sync') ?>
103 </a>
104
105 <p class="media-sync-scan-files-message">
106 <?= sprintf(__('Use this to see content of upload dir: %s and import files to Media Library.', 'media-sync'),
107 '<code title="'.$upload_dir_path.'">'.$relative_path.'</code>') ?>
108 </p>
109 <?php else: ?>
110 <p><i><?= $no_upload_dir_message ?></i></p>
111 <?php endif; ?>
112 </div>
113 <div class="card">
114 <h2 class="title"><?= __('Sync - Media Library', 'media-sync') ?></h2>
115 <a class="button button-primary" href="<?= add_query_arg(array('mode' => 'list', 'media_sync_missing_files' => 'yes'), get_admin_url(null, 'upload.php')) ?>">
116 <?= __('Filter Media Library', 'media-sync') ?>
117 </a>
118
119 <p class="media-sync-scan-files-message">
120 <?= __('Use this to see Media Library items that are missing actual files. This takes you to Media Library but with custom filter.', 'media-sync') ?>
121 </p>
122 </div>
123 <?php MediaSync::media_sync_render_pro_card(); ?>
124 <?php endif; ?>
125
126 <?php if ($scan_files) : ?>
127 <div class="media-sync-button-holder">
128 <a class="button button-primary" href="<?= add_query_arg('scan_files', 1, $here) ?>">
129 <?= __('Re-scan', 'media-sync') ?>
130 </a>
131 </div>
132
133 <div class="media-sync-button-holder">
134 <button class="button button-primary js-import-selected"><?= __('Import Selected', 'media-sync') ?></button>
135 <span class="spinner import-spinner"></span>
136 </div>
137
138 <div class="import-options">
139 <?php if ( !!get_option( 'ms_sg_use_dry_run', 1 ) ) : ?>
140 <div class="media-sync-dry-run-holder">
141 <input type="checkbox" id="dry-run" name="dry_run" checked="checked" />
142 <label for="dry-run"><?= __('Dry Run (test without making database changes)', 'media-sync') ?></label>
143 </div>
144 <?php else: ?>
145 <input type="hidden" name="dry_run" value="" />
146 <?php endif; ?>
147
148 <div class="import-option-date-type">
149 <label><?= __('Date/time to set for newly imported files', 'media-sync') ?>:</label>
150 <?php self::media_sync_render_post_date_option( 'file_post_date' ) ?>
151 </div>
152 <div class="import-option-batch-size">
153 <label for="batch-size">
154 <?= __('Batch Size:', 'media-sync') ?>
155 <input type="number" value="10" step="1" min="1" class="small-text" name="batch_size" id="batch-size" />
156 </label>
157 <p class="description">
158 <?= __('The number of files to process per network request.', 'media-sync') ?>
159 <?= __('A lower value might avoid some limits of your server (e.g. max_execution_time). ', 'media-sync') ?>
160 <br />
161 <?= __('If you still get errors, please try disabling other plugins related to files/images.', 'media-sync') ?>
162 <?= __('To reduce additional actions related to file uploads (e.g. generating additional thumbnails).', 'media-sync') ?>
163 </p>
164 </div>
165 </div>
166 <?php endif; ?>
167 </div>
168
169 <?php if ($scan_files) : ?>
170 <p class="media-sync-state-holder">
171 <span class="media-sync-progress-holder">
172 <span class="media-sync-progress"></span>
173 </span>
174 <span class="media-sync-state">
175 <span class="media-sync-state-text">
176 <?= __('Imported', 'media-sync') ?>
177 </span>
178 <span class="media-sync-state-number media-sync-imported-count js-media-sync-imported-count">0</span>
179 <span class="media-sync-state-text">
180 <?= __('out of', 'media-sync') ?>
181 </span>
182 <span class="media-sync-state-number media-sync-selected-count js-media-sync-selected-count">0</span>
183 <span class="media-sync-state-text">
184 <?= __('selected items', 'media-sync') ?>
185 </span>
186 </span>
187 <span class="media-sync-state media-sync-state-note">
188 <?= __('Files already in Media Library will be skipped during import', 'media-sync') ?>
189 </span>
190 </p>
191
192 <div class="wp-filter">
193 <div class="filter-items">
194 <label for="associated-filter" class="screen-reader-text"><?= __('Filter by type', 'media-sync') ?></label>
195 <select class="associated-filters" name="associated-filter" id="associated-filter">
196 <option value=""><?= __('All files', 'media-sync') ?></option>
197 <option value="missing_from:media_library"<?= $missing_from_ml ? 'selected="selected"' : '' ?>>
198 <?= __('Only files missing from Media Library', 'media-sync') ?>
199 </option>
200 </select>
201
202 <div class="actions">
203 <input type="submit" name="filter_action" id="post-query-submit" class="button" value="<?= __('Filter', 'media-sync') ?>">
204 </div>
205 </div>
206 </div>
207 <?php
208 $tree = $upload_dir_path ? self::media_sync_get_list_of_uploads() : array();
209 ?>
210 <?php if (!empty($tree)) : ?>
211 <div class="media-sync-table-holder">
212 <table class="wp-list-table widefat fixed media">
213 <?php self::media_sync_render_thead_tfoot_row('thead') ?>
214 <tbody id="the-list">
215 <?php foreach ($tree as $item) : ?>
216 <?php self::media_sync_render_row($item) ?>
217 <?php endforeach; ?>
218 </tbody>
219 <?php self::media_sync_render_thead_tfoot_row('tfoot') ?>
220 </table>
221 <span class="spinner is-active table-spinner"></span>
222 </div>
223 <?php else : ?>
224 <p class="media-sync-no-results">
225 <?php if(!$upload_dir_path) : ?>
226 <?= $no_upload_dir_message ?>
227 <?php else : ?>
228 <?= __('No Results', 'media-sync') ?>
229 <?php endif; ?>
230 </p>
231 <?php endif; ?>
232 <?php endif; ?>
233 </form>
234 </div>
235 </div>
236 <?php
237 }
238
239
240 /**
241 * Render error message when limits are reached.
242 *
243 * @param string $message
244 * @return void
245 */
246 static private function media_sync_render_error_message($message)
247 {
248 ?>
249 <div class="notice notice-error inline">
250 <p>
251 <?= __('Server has reached limits that prevent scanning all files at once.', 'media-sync') ?>
252 </p>
253 <p>
254 <strong><?= __('Caught error:', 'media-sync') ?></strong>
255 <?= $message ?>
256 </p>
257 </div>
258 <?php
259 MediaSync::media_sync_render_pro_card();
260 }
261
262
263 /**
264 * Get fatal error message if one occurred.
265 *
266 * @since 1.4.9
267 * @return string
268 */
269 static private function media_sync_get_fatal_error_message() {
270 try {
271 $error = error_get_last();
272 // Check if it's a fatal error and if it's related to memory or time
273 if ( $error && ( $error['type'] === E_ERROR || $error['type'] === E_USER_ERROR ) ) {
274 if ( strpos( $error['message'], 'Allowed memory size' ) !== false ) {
275 return sprintf(
276 __( 'Memory limit reached (%s used).', 'media-sync' ),
277 size_format( memory_get_peak_usage( true ) )
278 );
279 }
280 if ( strpos( $error['message'], 'Maximum execution time' ) !== false ) {
281 return sprintf(
282 __( 'Execution time limit reached (%s seconds).', 'media-sync' ),
283 ini_get( 'max_execution_time' )
284 );
285 }
286 }
287 } catch ( Exception $e ) {
288 }
289
290 return "";
291 }
292
293
294 /**
295 * @since 1.4.9
296 * @return void
297 */
298 static private function media_sync_render_pro_card() {
299 ?>
300 <div class="card">
301 <h2 class="title">Media Sync Pro</h2>
302 <a id="purchase-media-sync-pro" class="button button-primary" href="https://checkout.freemius.com/mode/dialog/plugin/14503/plan/24225/?show_monthly_switch=1" target="_blank" rel="nofollow noopener">Upgrade Now</a>
303 &nbsp;
304 <a class="button button-secondary" href="https://mediasyncplugin.com/?utm_source=base_plugin_banner&amp;utm_medium=init_page&amp;utm_campaign=bip" target="_blank" rel="noopener">Find out more</a>
305
306 <h4>Check out our newly revamped pro version with amazing new features.</h4>
307
308 <ul style="list-style: circle; margin-left: 15px;">
309 <li><strong>Revised incremental scan</strong>: Allows scanning and importing unlimited number of files.</li>
310 <li><strong>Quick single directory rescan</strong>: Easily rescan one directory to find new files or apply a different filter without reloading the whole page.</li>
311 <li><strong>Advanced filters</strong>: Find any file by customizing all default filters, search for a specific file type (images, videos, etc.), skip by tailor-made rules, or enter any custom pattern.</li>
312 <li><strong>Schedule automatic imports</strong>: Select a desired interval and let the plugin automatically import any new files it finds.</li>
313 <li><strong>Import logs</strong>: View the history of manual or scheduled imports.</li>
314 <li><strong>Limit plugin access</strong>: Limit plugin access to a specific role.</li>
315 </ul>
316 </div>
317 <?php
318 }
319
320
321 /**
322 * Prepare plugin settings
323 *
324 * @since 1.1.0
325 * @return void|boolean
326 */
327 static public function media_sync_options_setup()
328 {
329 if ( !current_user_can( 'manage_options' ) ) {
330 return false;
331 }
332
333 // From this plugin
334 add_option( 'ms_sg_use_dry_run', 1 );
335 add_option( 'ms_sg_file_post_date', 'default' );
336 register_setting( 'media-sync-settings-group', 'ms_sg_use_dry_run' );
337 register_setting( 'media-sync-settings-group', 'ms_sg_file_post_date' );
338
339 // From Add-On
340 do_action('media_sync_extended_advanced_options_register');
341
342 // Delete options that are no longer used
343 // delete_option('ms_sg_scan_sub_dir');
344 delete_option('ms_sg_use_debug');
345 }
346
347
348 /**
349 * Render plugin settings
350 *
351 * @since 1.1.0
352 * @return void|boolean
353 */
354 static public function media_sync_options_page()
355 {
356 if ( !current_user_can( 'manage_options' ) ) {
357 return false;
358 }
359 ?>
360 <div class="wrap media-sync-page-settings">
361 <h1><?= __('Media Sync Settings', 'media-sync') ?></h1>
362
363 <form method="post" action="options.php">
364 <?php
365 // From this plugin
366 settings_fields( 'media-sync-settings-group' );
367 do_settings_sections( 'media-sync-settings-group' );
368 ?>
369
370 <table class="form-table">
371 <tbody>
372 <?php
373 // From this plugin
374 ?>
375 <tr>
376 <th scope="row">
377 <label><?= __('Dry Run', 'media-sync') ?></label>
378 </th>
379 <td>
380 <fieldset>
381 <label for="ms-sg-use-dry-run">
382 <?php $checked = checked( 1, get_option( 'ms_sg_use_dry_run' ), false ); ?>
383 <input type="checkbox" name="ms_sg_use_dry_run" id="ms-sg-use-dry-run" value="1" <?= $checked ?>>
384 <?= __('Show "Dry Run" option', 'media-sync') ?>
385 </label>
386 </fieldset>
387 </td>
388 </tr>
389 <tr>
390 <th scope="row">
391 <label><?= __('Preselected / default value for file date', 'media-sync') ?></label>
392 </th>
393 <td>
394 <?php self::media_sync_render_post_date_option( 'ms_sg_file_post_date' ) ?>
395 </td>
396 </tr>
397 <?php
398 // From Add-On
399 do_action('media_sync_extended_advanced_options_render');
400 ?>
401 </tbody>
402 </table>
403
404 <?php submit_button(); ?>
405 </form>
406 </div>
407 <?php
408 }
409
410
411 /**
412 * Render option for selecting post date
413 *
414 * @since 1.1.0
415 * @param string $name Field name
416 * @return void
417 */
418 static public function media_sync_render_post_date_option($name)
419 {
420 $selected = self::sanitize_input_string(INPUT_GET, 'file_post_date');
421 if (empty($selected)) {
422 $selected = get_option( 'ms_sg_file_post_date' );
423 }
424 if (empty($selected)) {
425 $selected = 'default';
426 }
427 ?>
428 <fieldset>
429 <div class="import-option-date-type-item">
430 <label>
431 <input type="radio" name="<?= $name ?>" value="default" <?= checked( 'default', $selected ) ?>/>
432 <span><?= __('Default', 'media-sync') ?></span>
433 </label>
434 <p class="description"><?= __('Defaults to what WordPress uses - current time.', 'media-sync') ?></p>
435 </div>
436 <div class="import-option-date-type-item">
437 <label>
438 <input type="radio" name="<?= $name ?>" value="file_time" <?= checked( 'file_time', $selected ) ?>/>
439 <span><?= __('File time', 'media-sync') ?></span>
440 </label>
441 <p class="description"><?= __('File modification timestamp - when it was last modified.', 'media-sync') ?></p>
442 </div>
443 <div class="import-option-date-type-item">
444 <label>
445 <input type="radio" name="<?= $name ?>" value="smart_file_time" <?= checked( 'smart_file_time', $selected ) ?>/>
446 <span><?= __('Smart file time', 'media-sync') ?></span>
447 </label>
448 <p class="description"><?= sprintf(__('If file timestamp does not match the folder in which it is - it will construct the date/time based on the folder: %s.', 'media-sync'), '{year}-{month}-01 00:00:00') ?></p>
449 </div>
450 </fieldset>
451 <?php
452 }
453
454
455 /**
456 * Render table header and footer
457 *
458 * @since 0.1.0
459 * @param string $tag [thead|tfoot]
460 * @return void
461 */
462 static public function media_sync_render_thead_tfoot_row($tag)
463 {
464 $cb_id = 'cb-select-all-' . ($tag == 'thead' ? '1' : '2');
465 ?>
466 <<?= $tag ?>>
467 <tr>
468 <td class="manage-column check-column check-column-all"<?= $tag == 'thead' ? ' id="cb"':''?>>
469 <label class="screen-reader-text" for="<?= $cb_id ?>"><?= __('Select All', 'media-sync') ?></label>
470 <input id="<?= $cb_id ?>" type="checkbox">
471 </td>
472 <th scope="col" class="manage-column column-title column-primary"<?= $tag == 'thead' ? ' id="title"':''?>>
473 <span><?= __('File', 'media-sync') ?></span>
474 </th>
475 </tr>
476 </<?= $tag ?>>
477 <?php
478 }
479
480
481 /**
482 * Render table row for each file or directory
483 *
484 * @since 0.1.0
485 * @param array $item
486 * @return void
487 */
488 static public function media_sync_render_row($item)
489 {
490 $has_file_id = isset($item['file_id']) && $item['file_id'] !== false;
491 $url = $has_file_id ? esc_url(add_query_arg(array('post' => $item['file_id'], 'action' => 'edit'), get_admin_url(null, 'post.php'))) : $item['url'];
492 $url_attr = $item['is_dir'] !== true ? ' target="_blank"' : '';
493 $count_children = $item['count_children'];
494
495 $cls = 'media-sync-list-file';
496 $cls .= ' is-' . ($item['is_dir'] === true ? 'dir' : 'file');
497 $cls .= ' level-' . $item['level'];
498 $cls .= ' is-first-level-' . ($item['level'] === 1 ? 'yes' : 'no');
499 if ($item['is_dir'] !== true) {
500 $cls .= ' is-in-db-' . ($has_file_id ? 'yes' : 'no');
501 }
502
503 // This can be made optional, for a bit different UI
504 $toggle_arrows = true;
505 if ($toggle_arrows) {
506 $is_link = $item['is_dir'] !== true;
507 $cls .= ' toggle-arrows-yes';
508 } else {
509 $is_link = $item['is_dir'] !== true || $count_children > 0;
510 $url_attr .= ' class="js-toggle-row"';
511 }
512
513 $is_trash = isset($item['file_status']) && $item['file_status'] === 'trash';
514 ?>
515
516 <tr class="<?= $cls ?>" id="<?= $item['row_id'] ?>" data-parent-id="<?= $item['parent_id'] ?>">
517 <th scope="row" class="check-column">
518 <label class="screen-reader-text" for="cb-select-<?= $item['row_id'] ?>"></label>
519 <input type="checkbox" class="js-checkbox" id="cb-select-<?= $item['row_id'] ?>"
520 value="<?= $item['absolute_path'] ?>" data-row-id="<?= $item['row_id'] ?>">
521 </th>
522 <td class="title column-title has-row-actions column-primary" data-colname="<?= __('File', 'media-sync') ?>">
523 <?php if (!empty($item['parents'])) : ?>
524 <span class="media-sync-parents">
525 <?php foreach ($item['parents'] as $parent_key => $parent) : ?>
526 <?php
527 $parent_cls = 'media-sync-parent';
528 $parent_cls .= ' is-first-' . ($parent_key == 0 ? 'yes' : 'no');
529 $parent_cls .= ' is-last-' . ($parent_key + 1 == count($item['parents']) ? 'yes' : 'no');
530 ?>
531 <span class="<?= $parent_cls ?>"><i></i></span>
532 <?php endforeach; ?>
533 <span class="clearfix"></span>
534 </span>
535 <?php endif; ?>
536
537 <?php if ($toggle_arrows && $item['is_dir'] === true) : ?>
538 <span class="js-toggle-row media-sync-toggle-row dashicons"></span>
539 <?php endif; ?>
540
541 <?= $is_link ? '<a href="' . $item['url'] . '"' . $url_attr . '>' : '' ?>
542 <?php if ($item['is_dir'] === true) : ?>
543 <span class="dashicons dashicons-category"></span>
544 <?php endif; ?>
545 <span class="media-sync-file-name">
546 <?= $item['display_name'] ?>
547 </span>
548 <?= $is_link ? '</a>' : '' ?>
549
550 <?php if ($item['is_dir'] === true && $count_children !== null) : ?>
551 <span class="media-sync-num-items"><?= sprintf('(%u %s)', $count_children, $count_children == 1 ? __('item', 'media-sync') : __('items', 'media-sync')) ?></span>
552 <?php endif; ?>
553
554 <?php if ($has_file_id) : ?>
555 <span class="media-sync-already-in-db"> - <?= __('Already in', 'media-sync') ?>
556 <a href="<?= $url ?>" class="dashicons dashicons-admin-media" target="_blank"></a>
557 <?= $is_trash ? ' (' . __('In Trash', 'media-sync') . ')' : '' ?>
558 </span>
559 <?php endif; ?>
560 </td>
561 </tr>
562
563 <?php
564 if (!empty($item['children'])) :
565 foreach ($item['children'] as $child_item) :
566 self::media_sync_render_row($child_item);
567 endforeach;
568 endif;
569 }
570
571
572 /**
573 * Ajax action to import selected files
574 *
575 * @since 0.1.0
576 * @return string
577 */
578 static public function media_sync_import_files()
579 {
580 // Register shutdown function to catch fatal errors (e.g. memory exhausted)
581 register_shutdown_function(function() {
582 $message = self::media_sync_get_fatal_error_message();
583 if ( ! empty( $message ) ) {
584 wp_send_json_error(
585 __( 'Server has reached limits that prevented importing all selected files.', 'media-sync' ) . ' ' .
586 $message . ' ' .
587 sprintf(
588 __( '%s solves this by processing files in smaller batches.', 'media-sync' ),
589 '<a href="https://mediasyncplugin.com/?utm_source=base_plugin_error&utm_medium=scan_page&utm_campaign=esp" target="_blank">Media Sync Pro</a>'
590 )
591 );
592 }
593 });
594
595 if(!MediaSync::media_sync_user_has_general_access()) {
596 wp_send_json_error( __( 'You do not have sufficient permissions to access this page.', 'media-sync' ) );
597 }
598
599 check_ajax_referer( 'media_sync_import_files', 'security' );
600
601 // Get database stuff
602 global $wpdb;
603
604 $results = array();
605
606 // We can't apply sanitize_text_field here because file names might contain special characters (e.g. հ�
607 վիկ.jpg)
608 if(isset($_POST['media_items']) && !empty($_POST['media_items'])) {
609
610 // Make sure that this file is included, as wp_generate_attachment_metadata() depends on it.
611 require_once( ABSPATH . 'wp-admin' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'image.php' );
612
613
614 $files_in_db = self::media_sync_get_files_in_db();
615
616 $post_date_type = self::sanitize_input_string(INPUT_POST, 'file_post_date');
617 if (empty($post_date_type)) {
618 $post_date_type = get_option( 'ms_sg_file_post_date', 'default' );
619 }
620 $dry_run = self::filter_input_boolean(INPUT_POST, 'dry_run');
621
622 foreach ($_POST['media_items'] as $media_item) {
623
624 if(isset($media_item['file']) && !empty($media_item['file'])) {
625
626 // This comes from JS and it's taken from checkbox value, which is $item['absolute_path'] from media_sync_get_list_of_files()
627 $absolute_path = urldecode($media_item['file']);
628
629 $validated_path = self::media_sync_validate_path($absolute_path);
630 if ($validated_path === false) {
631 $results[] = array(
632 'row_id' => $media_item['row_id'],
633 'inserted' => false,
634 'errorMessage' => __('Invalid file path.', 'media-sync')
635 );
636 continue;
637 }
638 $absolute_path = $validated_path;
639
640 $relative_path = self::media_sync_url_encode(self::media_sync_get_relative_path($absolute_path));
641
642 // It's quicker to get all files already in db and check that array, than to do this query for each file
643 // $query = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE guid LIKE '%{$relative_path}'";
644 // $is_in_db = intval($wpdb->get_var($query)) > 0;
645
646 $is_in_db = isset($files_in_db[$relative_path]) && !empty($files_in_db[$relative_path]);
647
648 // Check if file is already in database
649 if(!$is_in_db) {
650
651 // Prepare data to be saved to `wp_posts` and `wp_postmeta`
652 $attachment = self::media_sync_prepare_attachment_data($absolute_path, $relative_path, $post_date_type);
653
654 if (!$attachment || isset($attachment['error']) || isset($attachment['errorMessage'])) {
655 // It's probably better to just continue importing other files
656 // echo json_encode($attachment);
657 // wp_die();
658
659 // This will mark this file as failed and show error below file name
660 $results[] = array(
661 'row_id' => $media_item['row_id'],
662 'inserted' => false,
663 'error' => isset($attachment['error']) ? $attachment['error'] : null,
664 'errorMessage' => isset($attachment['errorMessage']) ? $attachment['errorMessage'] : null
665 );
666 continue;
667 }
668
669 // If we're actually importing (creating database records)
670 if(!$dry_run) {
671
672 // Import file to database (`wp_posts` and `wp_postmeta`)
673 $import_response = self::media_sync_update_import_to_database($attachment, $absolute_path);
674
675 // Response is an array if there are errors or true if success
676 $is_inserted = $import_response === true;
677
678 // It's probably better to just continue importing other files
679 //if (!$is_inserted) {
680 // echo json_encode($import_response);
681 // wp_die();
682 //}
683
684 $results[] = array(
685 'row_id' => $media_item['row_id'],
686 'inserted' => $is_inserted,
687 'error' => isset($import_response['error']) ? $import_response['error'] : null,
688 'errorMessage' => isset($import_response['errorMessage']) ? $import_response['errorMessage'] : null
689 );
690 } else {
691 $results[] = array(
692 'row_id' => $media_item['row_id'],
693 'inserted' => true
694 );
695 }
696 }
697 }
698 }
699 }
700
701 echo json_encode(array(
702 'results' => $results
703 ));
704
705 wp_die(); // Must have for Ajax calls
706 }
707
708
709 /**
710 * Find mime type for file being imported
711 *
712 * @since 1.2.3
713 *
714 * @param string $absolute_path Absolute path to the file being imported
715 * @return string|array
716 */
717 static public function media_sync_get_mime_type($absolute_path)
718 {
719 try {
720 $mime_type = '';
721 if (function_exists('mime_content_type')) {
722 $mime_type = mime_content_type($absolute_path);
723 }
724
725 if (!empty($mime_type)) {
726 return $mime_type;
727 }
728
729 $file_type = wp_check_filetype(basename($absolute_path), null);
730
731 if(!$file_type['type']) {
732 return array(
733 'errorMessage' => sprintf(__('Invalid mime type for file: %s.', 'media-sync'), $absolute_path),
734 );
735 }
736
737 return $file_type['type'];
738
739 } catch (Exception $e) {
740 return array(
741 'errorMessage' => sprintf(__('Error getting mime type for file: %s.', 'media-sync'), $absolute_path),
742 'error' => $e->getMessage()
743 );
744 }
745 }
746
747 /**
748 * Prepare data to be imported to `wp_posts` table
749 *
750 * @since 1.2.2
751 *
752 * @param string $absolute_path Absolute path to the file being imported
753 * @param string $relative_path Path relative to the site URL
754 * @param string $post_date_type How to generate post date
755 * @return array
756 */
757 static public function media_sync_prepare_attachment_data($absolute_path, $relative_path, $post_date_type) {
758 try {
759 $mime_type = self::media_sync_get_mime_type( $absolute_path );
760
761 if ( isset( $mime_type['error'] ) || isset( $mime_type['errorMessage'] ) ) {
762 return $mime_type;
763 }
764
765 $decoded_relative_path = urldecode( $relative_path );
766
767 $title = self::media_sync_get_file_title( $absolute_path, $mime_type );
768
769 // Prepare an array of post data for the attachment.
770 $attachment = array(
771 'guid' => get_site_url() . $decoded_relative_path,
772 'post_mime_type' => $mime_type,
773 'post_title' => $title ?: preg_replace( '/\.[^.]+$/', '', basename( $decoded_relative_path ) ),
774 'post_content' => '',
775 'post_status' => 'inherit'
776 );
777
778 // Try to get post date based on settings - "Default" option will not set anything, so WP can use defaults
779 $post_date = self::media_sync_post_date( $absolute_path, $post_date_type );
780
781 if ( ! empty( $post_date ) ) {
782 $attachment['post_date'] = $post_date;
783 $attachment['post_date_gmt'] = $post_date;
784 }
785
786 return $attachment;
787 } catch ( Exception $e ) {
788 return array(
789 'errorMessage' => sprintf( __( 'Error preparing attachment data for file: %s.', 'media-sync' ), $absolute_path ),
790 'error' => $e->getMessage()
791 );
792 }
793 }
794
795
796 /**
797 * Try to extract an embedded title from a file's metadata.
798 * Returns null if no title is found, so the caller can fall back to the filename.
799 *
800 * @since 1.5.2
801 * @param string $absolute_path Absolute path to the file
802 * @param string $mime_type MIME type of the file
803 * @return string|null
804 */
805 static private function media_sync_get_file_title($absolute_path, $mime_type) {
806 try {
807 $is_image = strpos( $mime_type, 'image/' ) === 0;
808 if ( $is_image ) {
809 return self::media_sync_get_image_title( $absolute_path );
810 }
811 if ( $mime_type === 'application/pdf' ) {
812 return self::media_sync_get_pdf_title( $absolute_path );
813 }
814 } catch ( Exception $e ) {
815 }
816
817 return null;
818 }
819
820
821 /**
822 * Extract title from image EXIF metadata.
823 * Checks XPTitle (Windows, UTF-16LE) then ImageDescription.
824 *
825 * @since 1.5.2
826 * @param string $path Absolute path to the image file
827 * @return string|null
828 */
829 static private function media_sync_get_image_title($path) {
830 $exif_available = function_exists( 'exif_read_data' );
831 if ( ! $exif_available ) {
832 return null;
833 }
834
835 $exif = @exif_read_data( $path );
836 $exif_is_valid = is_array( $exif );
837 if ( ! $exif_is_valid ) {
838 return null;
839 }
840
841 $xp_title = ! empty( $exif['XPTitle'] ) ? $exif['XPTitle'] : null;
842 if ( $xp_title !== null ) {
843 $title = trim( mb_convert_encoding( $xp_title, 'UTF-8', 'UTF-16LE' ) );
844 if ( ! empty( $title ) ) {
845 return $title;
846 }
847 }
848
849 $image_description = ! empty( $exif['ImageDescription'] ) ? trim( $exif['ImageDescription'] ) : null;
850 if ( ! empty( $image_description ) ) {
851 return $image_description;
852 }
853
854 return null;
855 }
856
857
858 /**
859 * Extract title from PDF XMP metadata or info dictionary.
860 * Reads the first 128KB. XMP is tried first as it is more reliably near the start of the file.
861 *
862 * @since 1.5.2
863 * @param string $path Absolute path to the PDF file
864 * @return string|null
865 */
866 static private function media_sync_get_pdf_title($path) {
867 $content = @file_get_contents( $path, false, null, 0, 131072 );
868 $content_is_empty = $content === false || $content === '';
869 if ( $content_is_empty ) {
870 return null;
871 }
872
873 // XMP metadata stream: <dc:title> block (checked first more reliably near start of file)
874 $has_xmp = preg_match( '/<dc:title>.*?<rdf:li[^>]*>([^<]+)<\/rdf:li>.*?<\/dc:title>/s', $content, $xmp_matches );
875 if ( $has_xmp ) {
876 $title = trim( html_entity_decode( $xmp_matches[1], ENT_XML1 | ENT_QUOTES, 'UTF-8' ) );
877 if ( ! empty( $title ) ) {
878 return $title;
879 }
880 }
881
882 // Info dictionary literal string: /Title (value)
883 $has_literal = preg_match( '/\/Title\s*\(([^)\\\\]*(?:\\\\.[^)\\\\]*)*)\)/', $content, $literal_matches );
884 if ( $has_literal ) {
885 $raw = $literal_matches[1];
886 $raw = preg_replace_callback( '/\\\\([nrtbf()\\\\]|[0-7]{1,3})/', function ( $escape_match ) {
887 $char = $escape_match[1];
888 $map = [
889 'n' => "\n",
890 'r' => "\r",
891 't' => "\t",
892 'b' => "\x08",
893 'f' => "\x0C",
894 '(' => '(',
895 ')' => ')',
896 '\\' => '\\'
897 ];
898
899 return isset( $map[ $char ] ) ? $map[ $char ] : chr( octdec( $char ) );
900 }, $raw );
901 $has_utf16_bom = strlen( $raw ) >= 2 && "\xFE\xFF" === substr( $raw, 0, 2 );
902 if ( $has_utf16_bom ) {
903 $raw = mb_convert_encoding( substr( $raw, 2 ), 'UTF-8', 'UTF-16BE' );
904 }
905 $title = trim( $raw );
906 if ( ! empty( $title ) ) {
907 return $title;
908 }
909 }
910
911 // Info dictionary hex string: /Title <hex>
912 $has_hex = preg_match( '/\/Title\s*<([0-9a-fA-F\s]+)>/', $content, $hex_matches );
913 if ( $has_hex ) {
914 $hex = preg_replace( '/\s/', '', $hex_matches[1] );
915 $raw = @hex2bin( $hex );
916 if ( $raw ) {
917 $has_utf16_bom = strlen( $raw ) >= 2 && "\xFE\xFF" === substr( $raw, 0, 2 );
918 if ( $has_utf16_bom ) {
919 $raw = mb_convert_encoding( substr( $raw, 2 ), 'UTF-8', 'UTF-16BE' );
920 }
921 $title = trim( $raw );
922 if ( ! empty( $title ) ) {
923 return $title;
924 }
925 }
926 }
927
928 return null;
929 }
930
931
932 /**
933 * Import file to database (`wp_posts` and `wp_postmeta`)
934 *
935 * @since 1.2.1
936 *
937 * @param array $attachment Data for `wp_posts` table
938 * @param string $absolute_path Absolute path to the file being imported. Decoded.
939 * @return array|true
940 */
941 static public function media_sync_update_import_to_database($attachment, $absolute_path)
942 {
943 if (!stream_resolve_include_path($absolute_path)) {
944 return array(
945 'errorMessage' => sprintf(__('File to import not found at path: %s.', 'media-sync'), $absolute_path)
946 );
947 }
948
949 // Insert the attachment (`wp_posts` table)
950 try {
951 $max_execution_time = ini_get('max_execution_time');
952 if ($max_execution_time) {
953 // Reset execution time so that each file can be processed
954 set_time_limit($max_execution_time);
955 }
956
957 $attach_id = wp_insert_attachment($attachment, $absolute_path, 0, true);
958 } catch (Exception $e) {
959 return array(
960 'errorMessage' => sprintf(__('Error inserting attachment (`wp_posts` table) for file: %s.', 'media-sync'), $absolute_path),
961 'error' => $e->getMessage()
962 );
963 }
964
965 if (!($attach_id > 0)) {
966 return array(
967 'errorMessage' => sprintf(__('Attach ID not received for inserted attachment (`wp_posts` table) for file: %s.', 'media-sync'), $absolute_path)
968 );
969 }
970
971 try {
972 // Create custom meta tag (in `wp_postmeta` table) so that we later know this file was imported using this plugin
973 $media_sync_metadata = 1;
974 $media_sync_metadata = apply_filters('media_sync_filter_before_update_msc_metadata', $media_sync_metadata, $attach_id, $absolute_path);
975 if ($media_sync_metadata) {
976 add_post_meta($attach_id, '_msc', $media_sync_metadata, true);
977 }
978 } catch (Exception $e) {}
979
980 try {
981 // Generate the metadata for the attachment
982 $attach_data = wp_generate_attachment_metadata($attach_id, $absolute_path);
983 } catch (Exception $e) {
984 return array(
985 'errorMessage' => sprintf(__('Error generating attachment metadata (`wp_postmeta` table) for file: %s.', 'media-sync'), $absolute_path),
986 'error' => $e->getMessage()
987 );
988 }
989
990 if (!$attach_data || empty($attach_data)) {
991 return array(
992 'errorMessage' => sprintf(__('Attachment metadata could not be generated from %s.', 'media-sync'), $absolute_path),
993 'error' => json_encode($attachment)
994 );
995 }
996
997 try {
998 /**
999 * Apply this filter to collect additional metadata and/or to run some additional actions,
1000 * e.g. to "auto-connect" items to pages, posts, and WooCommerce products.
1001 * Returning empty data will skip updating metadata (wp_update_attachment_metadata),
1002 * so this filter can also be used to totally overwrite updating attachment metadata.
1003 *
1004 * This filter can be used in a number of different ways:
1005 * 1. to collect additional metadata,
1006 * 2. to run some additional custom actions
1007 * (e.g. to "auto-connect" items to pages, posts, and WooCommerce products),
1008 * 3. to skip or completely overwrite updating the metadata (`wp_update_attachment_metadata` function),
1009 * when this filter returns empty data (null).
1010 *
1011 * NOTE:
1012 * Things could break if the filter doesn't return proper attach data
1013 * and it hasn't created/updated the `wp_postmeta` table record (meta_key _wp_attachment_metadata).
1014 *
1015 * @since 1.2.5
1016 *
1017 * @param array $attach_data Data received from WP function: wp_generate_attachment_metadata
1018 * @param int $attach_id
1019 * @return array|null
1020 */
1021 $attach_data = apply_filters('media_sync_filter_before_update_metadata', $attach_data, $attach_id);
1022 } catch (Exception $e) {
1023 return array(
1024 'errorMessage' => sprintf(__('Error processing filter media_sync_filter_before_update_metadata for file: %s.', 'media-sync'), $absolute_path),
1025 'error' => $e->getMessage()
1026 );
1027 }
1028
1029 // There is this same validation (if) before "media_sync_filter_before_update_metadata" filter,
1030 // so if we end up here, it's because of that filter. Which either already updated metadata,
1031 // or doesn't want to update it (basically telling us not to run wp_update_attachment_metadata).
1032 if (!$attach_data || empty($attach_data)) {
1033 return true;
1034 }
1035
1036 try {
1037 // Update `wp_postmeta` database record
1038 wp_update_attachment_metadata($attach_id, $attach_data);
1039 } catch (Exception $e) {
1040 return array(
1041 'errorMessage' => sprintf(__('Error updating attachment metadata (`wp_postmeta` table) for file: %s.', 'media-sync'), $absolute_path),
1042 'error' => $e->getMessage()
1043 );
1044 }
1045
1046 return true;
1047 }
1048
1049
1050 /**
1051 * Returns post date for file being imported based on selected option
1052 *
1053 * @since 1.1.0
1054 * @param string $file_path Absolute path to file being imported
1055 * @param string $type Selected option telling which date to find [default|file_time|smart_file_time]
1056 * @return string
1057 */
1058 static public function media_sync_post_date($file_path, $type)
1059 {
1060 // Default is empty string so that WordPress can set its default value (current date)
1061 $post_date = '';
1062
1063 try {
1064
1065 // Take "time modified" of file being imported
1066 $file_timestamp = filemtime( $file_path );
1067 if($file_timestamp) {
1068
1069 // Convert to datetime
1070 $file_time = date( 'Y-m-d H:i:s', $file_timestamp );
1071 if($file_time) {
1072
1073 // For "File time" option - return file time
1074 if($type === 'file_time') {
1075 $post_date = $file_time;
1076 }
1077
1078 // For "Smart file time" option - determine post date by comparing file's modified date with folder in which it is
1079 if($type === 'smart_file_time') {
1080 // Use file time as a fallback
1081 $post_date = $file_time;
1082
1083 // Compare the file date with the folder date structure
1084 // Try to extract year and month from file path (e.g. "2020/02" or "2020\02")
1085 // But this path is now always using forward slash (even on Windows Server), so we can again use just '/(\d{4})\/(\d{2})/'
1086 preg_match('/(\d{4})[\/|\\\\](\d{2})/', $file_path, $matches);
1087 if ( !empty($matches) ) {
1088 $year_and_month = $matches[1] . '-' . $matches[2];
1089 $folder_time = $year_and_month . '-' . '01' . ' 00:00:00';
1090
1091 // If what we found for folder date is a date (double check for regex above)
1092 if ( date('Y-m-d H:i:s', strtotime( $folder_time )) == $folder_time ) {
1093
1094 // If file's year and month values do not match with folder's year and month
1095 if ( date( 'Y-m', $file_timestamp ) != $year_and_month ) {
1096 // Use the folder date instead
1097 $post_date = $folder_time;
1098 }
1099 }
1100 }
1101 }
1102 }
1103 }
1104
1105
1106 // Receive external function for setting custom post date
1107 $external = apply_filters('media_sync_filter_post_date', $post_date, $file_path, $type );
1108
1109 // Return received custom post date if it's empty string or a date
1110 if( is_string($external) && ($external === '' || ( date('Y-m-d H:i:s', strtotime( $external )) == $external) ) ) {
1111 $post_date = $external;
1112 }
1113
1114 return $post_date;
1115 } catch ( Exception $e ) {
1116 return $post_date;
1117 }
1118 }
1119
1120
1121 /**
1122 * Get path of directory used by this plugin as main import directory (uploads dir).
1123 *
1124 * Not normalizing dir path anymore (keeping backslashes on Windows Server),
1125 * to avoid issues with relative path when importing.
1126 *
1127 * @since 1.1.3
1128 * @return string|null
1129 */
1130 static private function media_sync_get_uploads_basedir()
1131 {
1132 $upload_dir = wp_get_upload_dir();
1133
1134 if(!($upload_dir && isset($upload_dir['basedir']) && !empty($upload_dir['basedir']))) {
1135 return null;
1136 }
1137
1138 return $upload_dir['basedir'];
1139 }
1140
1141
1142 /**
1143 * Validate that a path resolves within the uploads directory.
1144 *
1145 * @since 1.5.0
1146 * @param string $path Absolute path to validate
1147 * @return string|false Resolved canonical path, or false if invalid
1148 */
1149 static private function media_sync_validate_path($path)
1150 {
1151 // Reject stream wrappers e.g. ftp://, php://
1152 if (strpos($path, '://') !== false) {
1153 return false;
1154 }
1155
1156 // Reject path traversal sequences
1157 if (strpos($path, '..') !== false) {
1158 return false;
1159 }
1160
1161 // Resolve canonical uploads base path
1162 $uploads_basedir = realpath(self::media_sync_get_uploads_basedir());
1163 if ($uploads_basedir === false) {
1164 return false;
1165 }
1166
1167 // Resolve canonical path (also confirms the path exists on disk)
1168 $resolved = realpath($path);
1169 if ($resolved === false) {
1170 return false;
1171 }
1172
1173 // Confirm resolved path is within uploads
1174 if (strpos($resolved, $uploads_basedir) !== 0) {
1175 return false;
1176 }
1177
1178 return $path;
1179 }
1180
1181
1182 /**
1183 * Get path absolute to WP root. Always using forward slashes.
1184 *
1185 * e.g. /var/www/WP/wp-content/uploads -> /wp-content/uploads
1186 * e.g. C:/www/WP/wp-content/uploads -> /wp-content/uploads
1187 *
1188 * @since 1.1.3
1189 * @param string $absolute_path Absolute file path. Will be converted to forward slashes.
1190 * @return string
1191 */
1192 static private function media_sync_get_relative_path($absolute_path)
1193 {
1194 // Since get_home_path() and WP in general always use forward slashes, we need to convert it as well
1195 $absolute_path = wp_normalize_path($absolute_path);
1196
1197 // Always using forward slash
1198 return str_replace(get_home_path(), '/', $absolute_path);
1199 }
1200
1201
1202 /**
1203 * Scan "uploads" directory and return recursive list of files and directories
1204 *
1205 * @since 0.1.0
1206 * @return Generator
1207 */
1208 static private function media_sync_get_list_of_uploads()
1209 {
1210 self::$start_time = microtime(true);
1211
1212 self::$upload_dir_path = self::media_sync_get_uploads_basedir();
1213 if(!self::$upload_dir_path) {
1214 return array();
1215 }
1216
1217 // Limit scanning to specific sub folder or encoded path (e.g. &sub_dir=2020%2F01)
1218 $sub_dir = self::sanitize_input_string(INPUT_GET, 'sub_dir');
1219 if ($sub_dir) {
1220 $sub_dir_validated = self::media_sync_validate_path( self::$upload_dir_path . '/' . $sub_dir );
1221 if ( $sub_dir_validated === false ) {
1222 return array();
1223 }
1224 self::$upload_dir_path = $sub_dir_validated;
1225 }
1226
1227 if(empty(self::$files_in_db)) {
1228 self::$files_in_db = self::media_sync_get_files_in_db();
1229 }
1230
1231 $associated_filter = self::sanitize_input_string(INPUT_GET, 'associated-filter');
1232 $is_missing_from_ml_filter = $associated_filter && urldecode($associated_filter) === 'missing_from:media_library';
1233
1234 // Clear cached files (affecting file_exists, stream_resolve_include_path, etc.)
1235 clearstatcache();
1236
1237 // Get all files - returning Generator (not Array)
1238 return self::media_sync_get_list_of_files(self::$upload_dir_path, self::$files_in_db, $is_missing_from_ml_filter);
1239 }
1240
1241
1242 /**
1243 * Scan directory (passed as first value) and return recursive list of files and directories
1244 *
1245 * @since 0.1.0
1246 * @param string $current_dir_path Changing recursively for each directory that gets iterated
1247 * @param array $files_in_db List of files that are already in database
1248 * @param bool $is_missing_from_ml_filter Filter by "association"
1249 * @return Generator
1250 */
1251 static private function media_sync_get_list_of_files($current_dir_path, $files_in_db, $is_missing_from_ml_filter)
1252 {
1253 if(!self::$upload_dir_path) {
1254 yield null;
1255 }
1256
1257 // "stream_resolve_include_path" is like "file_exists" but should be quicker
1258 if( !stream_resolve_include_path($current_dir_path) ) {
1259 yield null;
1260 }
1261
1262 foreach (scandir($current_dir_path) as $key => $file_name) {
1263 // Skip ".", "..", etc.
1264 if ($file_name == '.' || $file_name == '..' || $file_name == ".DS_Store" || $file_name == ".htaccess" || $file_name == "index.php") {
1265 continue;
1266 }
1267
1268 $full_path = $current_dir_path . '/' . $file_name;
1269
1270 $isDir = is_dir($full_path);
1271
1272 // Default file skipping rules:
1273 // 1. contains image size at the end (e.g. -100x100.jpg), or
1274 // 2. ends with "-scaled" (also WP generated), or
1275 // 3. retina thumbnail (e.g. -100x100@2x.jpg), or
1276 // 4. has .webp after another file type (e.g. .jpg.webp).
1277 $is_ignored = preg_match('/(-scaled|[_-]\d+x\d+)|@[2-6]x(?=\.[a-z]{3,4}$)|\.[a-z]{3,4}.webp/im', $file_name) == true;
1278
1279 // Receive external rules for skipping files/folders
1280 $is_ignored_external = apply_filters('media_sync_filter_is_scan_object_ignored', $is_ignored, $full_path, $file_name);
1281
1282 // Take custom rule for skipping files/folders from external hook/filter function
1283 if (is_bool($is_ignored_external)) {
1284 $is_ignored = $is_ignored_external;
1285 }
1286
1287
1288 if ($is_ignored) {
1289 continue;
1290 }
1291
1292
1293 $relative_path = self::media_sync_url_encode(self::media_sync_get_relative_path($full_path));
1294 $file_in_db = isset($files_in_db[$relative_path]) && !empty($files_in_db[$relative_path]) ?
1295 $files_in_db[$relative_path] : false;
1296 $file_id = $file_in_db && !empty($file_in_db['id']) ? $file_in_db['id'] : false;
1297 $file_status = $file_in_db && !empty($file_in_db['status']) ? $file_in_db['status'] : false;
1298
1299 if (!$isDir && $is_missing_from_ml_filter && $file_id !== false) {
1300 continue;
1301 }
1302
1303
1304 // Ugly, but efficient
1305 $children = array();
1306
1307 if ($isDir) {
1308 $children = self::media_sync_get_list_of_files($full_path, $files_in_db, $is_missing_from_ml_filter);
1309
1310 if (empty($children)) {
1311 continue;
1312 }
1313 }
1314
1315 // Get current parents, to get for example: "/2012/03"
1316 $parents_path = str_replace(self::$upload_dir_path, '', $current_dir_path);
1317
1318 // Trim first slash, to get for example: "2012/03"
1319 $parents_path = ltrim($parents_path, '/');
1320
1321 // Since this path is always using forward slashes, that's what we'll use here
1322 $parents = !empty($parents_path) ? explode('/', $parents_path) : array();
1323
1324 $item = array(
1325 'display_name' => $file_name,
1326 'is_dir' => !!$isDir,
1327 'level' => count($parents) + 1,
1328 'row_id' => self::get_row_id( self::media_sync_url_encode( $parents_path . '/' . $file_name ) ),
1329 'parent_id' => self::get_row_id( self::media_sync_url_encode( $parents_path ) ),
1330 'parents' => $parents,
1331 'absolute_path' => self::media_sync_url_encode($full_path)
1332 );
1333
1334 if ($isDir) {
1335 $item['url'] = 'javascript:;';
1336 $item['children'] = $children;
1337 // Not so easy when using generators (yield)
1338 $item['count_children'] = null;
1339 } else {
1340 $item['url'] = get_site_url() . $relative_path;
1341 $item['file_id'] = $file_id;
1342 $item['file_status'] = $file_status;
1343 $item['children'] = [];
1344 $item['count_children'] = 0;
1345 }
1346
1347 yield $item;
1348 }
1349 }
1350
1351
1352 /**
1353 * Get row id from path.
1354 * Cleanup since this will be used as HTML attribute.
1355 *
1356 * @param string $encoded_path
1357 * @return string
1358 * @since 1.4.5
1359 */
1360 static public function get_row_id(string $encoded_path): string {
1361 if ( empty( $encoded_path ) ) {
1362 return "";
1363 }
1364
1365 return 'msc-' . str_replace( '.', '-', sanitize_file_name( $encoded_path ) );
1366 }
1367
1368
1369 /**
1370 * Convert special characters to safe characters but keeping forward slash.
1371 *
1372 * @param $url
1373 * @return string
1374 * @since 1.2.4
1375 */
1376 static private function media_sync_url_encode($url)
1377 {
1378 return str_replace('%2F', '/', rawurlencode($url));
1379 }
1380
1381
1382 /**
1383 * Get list of files that are already in database
1384 *
1385 * Caching does not seem to work, disabled for now
1386 *
1387 * @since 0.1.0
1388 * @return array
1389 */
1390 static private function media_sync_get_files_in_db()
1391 {
1392 $media_query = new WP_Query( array(
1393 'post_type' => 'attachment',
1394 'post_status' => array( 'inherit', 'trash' ),
1395 'posts_per_page' => - 1
1396 ) );
1397
1398 $upload_dir_path = self::media_sync_get_uploads_basedir();
1399 $upload_dir_relative_path = self::media_sync_get_relative_path( $upload_dir_path );
1400
1401 $files = array();
1402 foreach ( $media_query->posts as $post ) {
1403
1404 $file_path = get_post_meta( $post->ID, '_wp_attached_file', true );
1405 if ( empty( $file_path ) ) {
1406 continue;
1407 }
1408
1409 // e.g. /2012/03/img space.jpg
1410 $short_relative_path = '/' . $file_path;
1411
1412 // e.g. /wp-content/uploads/2012/03/img%20space.jpg
1413 $relative_path = self::media_sync_url_encode( $upload_dir_relative_path . $short_relative_path );
1414
1415 $file = array(
1416 'id' => $post->ID,
1417 'name' => $post->post_title,
1418 'status' => $post->post_status
1419 );
1420
1421 $files[ $relative_path ] = $file;
1422
1423 // Path to current file without file name
1424 $base_path = self::get_base_path( $file_path );
1425
1426 // For large images - WordPress creates resized versions ("-scaled" at the end of file)
1427 // https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/
1428 // So we also need to find and treat original file as "file in db"
1429 //
1430 // WordPress 7.1 client-side media processing also writes companion basenames into
1431 // metadata: the HEIC/HEIF original kept beside its JPEG derivative, and the MP4 and
1432 // poster JPEG generated from an animated GIF. All four keys resolve the same way.
1433 $meta = wp_get_attachment_metadata( $post->ID );
1434 foreach (
1435 array(
1436 'original_image',
1437 'source_image',
1438 'animated_video',
1439 'animated_video_poster'
1440 ) as $companion_key
1441 ) {
1442 if ( ! empty( $meta[ $companion_key ] ) && is_string( $meta[ $companion_key ] ) ) {
1443 $companion_path = self::media_sync_url_encode( $upload_dir_relative_path . $base_path . $meta[ $companion_key ] );
1444 $files[ $companion_path ] = $file;
1445 }
1446 }
1447
1448 // Check if this is an edited image (crop, rotate, etc. in Media Library) and treat all those edits as "files in db"
1449 $backup_sizes = get_post_meta( $post->ID, '_wp_attachment_backup_sizes', true );
1450 if ( ! empty( $backup_sizes ) ) {
1451 foreach ( $backup_sizes as $backup_size ) {
1452 $backup_size_path = self::media_sync_url_encode( $upload_dir_relative_path . $base_path . $backup_size['file'] );
1453 $files[ $backup_size_path ] = $file;
1454 }
1455 }
1456 }
1457
1458 return $files;
1459 }
1460
1461
1462 /**
1463 * Take base path from provided file path.
1464 *
1465 * @param string $file_path
1466 * @return string
1467 * @since 1.3.3
1468 */
1469 static private function get_base_path($file_path)
1470 {
1471 $base_path = pathinfo($file_path, PATHINFO_DIRNAME);
1472 if ($base_path === '.') {
1473 return '/';
1474 } else {
1475 return '/' . rtrim($base_path, '/') . '/';
1476 }
1477 }
1478
1479
1480 /**
1481 * Parse the memory_limit variable from the php.ini file.
1482 *
1483 * @since 1.2.0
1484 * @return int
1485 */
1486 static private function media_sync_get_memory_limit()
1487 {
1488 $limit_string = ini_get('memory_limit');
1489 $unit = strtolower(mb_substr($limit_string, -1 ));
1490 $bytes = intval(mb_substr($limit_string, 0, -1), 10);
1491
1492 switch ($unit)
1493 {
1494 case 'k':
1495 $bytes *= 1024;
1496 break 1;
1497
1498 case 'm':
1499 $bytes *= 1024 * 1024;
1500 break 1;
1501
1502 case 'g':
1503 $bytes *= 1024 * 1024 * 1024;
1504 break 1;
1505
1506 default:
1507 break 1;
1508 }
1509
1510 return $bytes;
1511 }
1512
1513
1514 /**
1515 * Check if logged in user has access
1516 *
1517 * @since 1.1.0
1518 * @return boolean
1519 */
1520 static public function media_sync_user_has_general_access()
1521 {
1522 if ( !current_user_can( 'upload_files' ) ) {
1523 return false;
1524 }
1525
1526 return true;
1527 }
1528
1529
1530 /**
1531 * Sanitize a string input from GET or POST request.
1532 * Be careful not to strip special characters from file or directory name.
1533 *
1534 * @param int $type The input type (INPUT_GET, INPUT_POST).
1535 * @param string $input_name The name of the input to retrieve and sanitize.
1536 * @return string The sanitized string.
1537 * @since 1.4.1
1538 */
1539 static public function sanitize_input_string($type, $input_name)
1540 {
1541 $value = filter_input( $type, $input_name, FILTER_SANITIZE_FULL_SPECIAL_CHARS ) ?: '';
1542
1543 return ! empty( $value ) ? sanitize_text_field( $value ) : '';
1544 }
1545
1546
1547 /**
1548 * Sanitize a boolean input from GET or POST request.
1549 *
1550 * @param int $type The input type (INPUT_GET, INPUT_POST).
1551 * @param string $input_name The name of the input to retrieve and sanitize.
1552 * @return bool The sanitized boolean.
1553 * @since 1.4.1
1554 */
1555 static public function filter_input_boolean($type, $input_name)
1556 {
1557 return (bool) filter_input($type, $input_name, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1558 }
1559 }
1560 endif; // End if class_exists check.
1561 ?>