array( 'name' => 'FileBird', 'kind' => 'filebird', ), 'rml' => array( 'name' => 'Real Media Library', 'kind' => 'rml', ), 'happyfiles' => array( 'name' => 'HappyFiles', 'kind' => 'taxonomy', 'taxonomy' => 'happyfiles_category', ), 'eml' => array( 'name' => 'Enhanced Media Library', 'kind' => 'taxonomy', 'taxonomy' => 'media_category', ), 'folders' => array( 'name' => 'Folders (Premio)', 'kind' => 'taxonomy', 'taxonomy' => 'media_folder', ), ); } /** * Whether a database table exists (another plugin's own table). * * @param string $table The table's full name. * @return bool True when it does. */ private static function table_exists( string $table ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only check whether another plugin's own table exists; no WordPress API covers it. return $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); } /** * Folders of one source: id => name, parent (0 = top), owner (FileBird's per-user folders). * * @param string $key The source plugin's key. * @return array Folder ID => name, parent and owner. */ public static function folders( string $key ): array { global $wpdb; $src = self::sources()[ $key ] ?? null; if ( ! $src ) { return array(); } $out = array(); if ( 'filebird' === $src['kind'] ) { if ( ! self::table_exists( $wpdb->prefix . 'fbv' ) ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only read of FileBird's own folder table (type 0 = media folders), bounded. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, name, parent, created_by FROM %i WHERE type = 0 ORDER BY id LIMIT %d', $wpdb->prefix . 'fbv', self::MAX_FOLDERS + 1 ), ARRAY_A ); foreach ( (array) $rows as $r ) { $out[ (int) $r['id'] ] = array( 'name' => (string) $r['name'], 'parent' => max( 0, (int) $r['parent'] ), 'owner' => max( 0, (int) $r['created_by'] ), ); } } elseif ( 'rml' === $src['kind'] ) { if ( ! self::table_exists( $wpdb->prefix . 'realmedialibrary' ) ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only read of Real Media Library's own folder table (top level = parent -1), bounded. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, name, parent FROM %i ORDER BY id LIMIT %d', $wpdb->prefix . 'realmedialibrary', self::MAX_FOLDERS + 1 ), ARRAY_A ); foreach ( (array) $rows as $r ) { $out[ (int) $r['id'] ] = array( 'name' => (string) $r['name'], 'parent' => max( 0, (int) $r['parent'] ), 'owner' => 0, ); } } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only read of another plugin's terms; its taxonomy may not be registered while it is inactive, so get_terms() cannot be used. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT t.term_id AS id, t.name, tt.parent FROM %i t INNER JOIN %i tt ON tt.term_id = t.term_id WHERE tt.taxonomy = %s ORDER BY t.term_id LIMIT %d', $wpdb->terms, $wpdb->term_taxonomy, $src['taxonomy'], self::MAX_FOLDERS + 1 ), ARRAY_A ); foreach ( (array) $rows as $r ) { $out[ (int) $r['id'] ] = array( 'name' => (string) $r['name'], 'parent' => max( 0, (int) $r['parent'] ), 'owner' => 0, ); } } return $out; } /** * Folder-file pairs, in a stable order, from an offset. Each pair: [source folder id, attachment id]. * * @param string $key The source plugin's key. * @param int $offset Where to start. * @param int $limit How many pairs. * @return array Pairs of source folder ID and attachment ID. */ private static function pairs( string $key, int $offset, int $limit ): array { global $wpdb; $src = self::sources()[ $key ]; if ( 'filebird' === $src['kind'] ) { if ( ! self::table_exists( $wpdb->prefix . 'fbv_attachment_folder' ) ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only page of FileBird's folder relationships. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT folder_id AS f, attachment_id AS a FROM %i ORDER BY folder_id, attachment_id LIMIT %d OFFSET %d', $wpdb->prefix . 'fbv_attachment_folder', $limit, $offset ), ARRAY_A ); } elseif ( 'rml' === $src['kind'] ) { if ( ! self::table_exists( $wpdb->prefix . 'realmedialibrary_posts' ) ) { return array(); } // A shortcut is its own attachment whose isShortcut holds the original's ID: the original goes into that folder too. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only page of Real Media Library's folder relationships. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT fid AS f, IF(isShortcut > 0, isShortcut, attachment) AS a FROM %i WHERE fid > 0 ORDER BY attachment, isShortcut LIMIT %d OFFSET %d', $wpdb->prefix . 'realmedialibrary_posts', $limit, $offset ), ARRAY_A ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only page of another plugin's term relationships (its taxonomy may be unregistered). $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT tt.term_id AS f, tr.object_id AS a FROM %i tr INNER JOIN %i tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = %s ORDER BY tr.object_id, tt.term_id LIMIT %d OFFSET %d', $wpdb->term_relationships, $wpdb->term_taxonomy, $src['taxonomy'], $limit, $offset ), ARRAY_A ); } return array_map( static fn( $r ) => array( (int) $r['f'], (int) $r['a'] ), (array) $rows ); } /** * How many folder-file pairs a source has. * * @param string $key The source plugin's key. * @return int The count. */ public static function pair_count( string $key ): int { global $wpdb; $src = self::sources()[ $key ]; if ( 'filebird' === $src['kind'] ) { if ( ! self::table_exists( $wpdb->prefix . 'fbv_attachment_folder' ) ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only count for the import preview. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'fbv_attachment_folder' ) ); } if ( 'rml' === $src['kind'] ) { if ( ! self::table_exists( $wpdb->prefix . 'realmedialibrary_posts' ) ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only count for the import preview. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE fid > 0', $wpdb->prefix . 'realmedialibrary_posts' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only count for the import preview. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i tr INNER JOIN %i tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = %s', $wpdb->term_relationships, $wpdb->term_taxonomy, $src['taxonomy'] ) ); } /** What the Library shows before anything is copied. */ public static function found(): array { $out = array(); foreach ( self::sources() as $key => $src ) { $folders = self::folders( $key ); if ( $folders ) { $out[ $key ] = array( 'name' => $src['name'], 'folders' => count( $folders ), 'pairs' => self::pair_count( $key ), 'too_many' => count( $folders ) > self::MAX_FOLDERS, ); } } return $out; } /** * The saved import. * * @return array The import, or an empty array. */ public static function state(): array { $s = Settings::fresh_option( self::OPTION, array() ); return is_array( $s ) && 1 === ( $s['v'] ?? 0 ) ? $s : array(); } /** * A source folder's name made safe as plain text, at most 120 characters (a folder without a name is named after its * number). * * @param string $name The name in the other plugin. * @param int $id Its number there. * @return string The name. */ private static function clean_name( string $name, int $id ): string { $name = trim( sanitize_text_field( html_entity_decode( $name, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); if ( function_exists( 'mb_substr' ) ) { $name = mb_substr( $name, 0, 120 ); } else { $name = substr( $name, 0, 120 ); } /* translators: %d: the folder's number in the other plugin. */ return '' !== $name ? $name : sprintf( __( 'Folder %d', 'affstackdev-image-manager' ), $id ); } /** * One Image Manager folder for a slug, reusing a folder of the same name under the same parent. * * @param string $slug The folder's slug (import--). * @param string $name Its name. * @param int $parent Its parent folder (0 for the top). * @param array $counts Folders made and reused so far, changed in place. * @return int The folder's term ID. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function folder( string $slug, string $name, int $parent, array &$counts ): int { $term = get_term_by( 'slug', $slug, Library::FOLDER ); if ( $term && ! is_wp_error( $term ) ) { ++$counts['reused']; return (int) $term->term_id; } $made = Reviewed_Store::wp_insert_term( $name, Library::FOLDER, array( 'slug' => $slug, 'parent' => $parent, ) ); if ( is_wp_error( $made ) ) { $existing = (int) $made->get_error_data( 'term_exists' ); if ( 'term_exists' === $made->get_error_code() && $existing ) { ++$counts['reused']; return $existing; } throw new \RuntimeException( 'folder_import_failed' ); } ++$counts['created']; return (int) $made['term_id']; } /** * Creates the whole tree, parents first. A folder whose parent is missing goes to the top level. * * @param string $key The source plugin's key. * @param array $folders Its folders (folders()). * @return array Source folder ID => Image Manager folder ID, and the counts made and reused. */ private static function tree( string $key, array $folders ): array { $map = array(); $counts = array( 'created' => 0, 'reused' => 0, ); $users = array(); $depth = static function ( int $id ) use ( $folders ): int { $d = 0; $seen = array(); while ( $id && isset( $folders[ $id ] ) && ! isset( $seen[ $id ] ) && $d < 64 ) { $seen[ $id ] = true; $id = $folders[ $id ]['parent']; ++$d; } return $d; }; $order = array_keys( $folders ); usort( $order, static fn( $a, $b ) => $depth( $a ) <=> $depth( $b ) ?: $a <=> $b ); foreach ( $order as $id ) { $f = $folders[ $id ]; $parent = 0; if ( $f['parent'] && isset( $map[ $f['parent'] ] ) ) { $parent = $map[ $f['parent'] ]; } elseif ( $f['owner'] ) { if ( ! isset( $users[ $f['owner'] ] ) ) { $user = get_userdata( $f['owner'] ); /* translators: %s: name of the person whose own FileBird folders these were. */ $users[ $f['owner'] ] = self::folder( 'import-' . $key . '-user-' . $f['owner'], self::clean_name( sprintf( __( '%s (FileBird)', 'affstackdev-image-manager' ), $user ? $user->display_name : '#' . $f['owner'] ), 0 ), 0, $counts ); } $parent = $users[ $f['owner'] ]; } $map[ $id ] = self::folder( 'import-' . $key . '-' . $id, self::clean_name( $f['name'], $id ), $parent, $counts ); } Library::clear_terms(); return array( $map, $counts ); } /** * Start (confirmed) or continue an import. Each request works for a few seconds and saves its position. * * @param string $key The source plugin's key. * @param bool $start True to start the import. * @param bool $confirmed The owner's confirmation (needed to start). * @return array The import. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function step( string $key, bool $start, bool $confirmed ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { throw new \RuntimeException( 'folder_import_access' ); } if ( ! isset( self::sources()[ $key ] ) ) { throw new \RuntimeException( 'folder_import_source' ); } $token = Repository::acquire( 'folder_import', 300 ); if ( ! $token ) { throw new \RuntimeException( 'folder_import_busy' ); } try { $state = self::state(); if ( $start ) { if ( ! $confirmed ) { throw new \RuntimeException( 'folder_import_confirm' ); } if ( $state && 'more' === $state['status'] && $state['source'] !== $key ) { throw new \RuntimeException( 'folder_import_other' ); } $folders = self::folders( $key ); if ( ! $folders ) { throw new \RuntimeException( 'folder_import_source' ); } if ( count( $folders ) > self::MAX_FOLDERS ) { throw new \RuntimeException( 'folder_import_too_many' ); } list( $map, $counts ) = self::tree( $key, $folders ); $state = array( 'v' => 1, 'source' => $key, 'actor' => get_current_user_id(), 'started' => time(), 'updated' => time(), 'map' => $map, 'created' => $counts['created'], 'reused' => $counts['reused'], 'cursor' => 0, 'total' => self::pair_count( $key ), 'placed' => 0, 'missing' => 0, 'status' => 'more', ); } elseif ( ! $state || $state['source'] !== $key || 'more' !== $state['status'] ) { throw new \RuntimeException( 'folder_import_stale' ); } $until = microtime( true ) + 8; do { $pairs = self::pairs( $key, (int) $state['cursor'], self::CHUNK ); $by_file = array(); foreach ( $pairs as list( $f, $a ) ) { if ( isset( $state['map'][ $f ] ) && $a > 0 ) { $by_file[ $a ][] = (int) $state['map'][ $f ]; } else { ++$state['missing']; } } foreach ( $by_file as $a => $terms ) { if ( 'attachment' !== get_post_type( $a ) || ! current_user_can( 'edit_post', $a ) ) { $state['missing'] += count( $terms ); continue; } // Added to the folder, never taken out of one it is already in. $set = Reviewed_Store::wp_set_object_terms( $a, array_values( array_unique( $terms ) ), Library::FOLDER, true ); if ( is_wp_error( $set ) ) { throw new \RuntimeException( 'folder_import_failed' ); } $state['placed'] += count( $terms ); } $state['cursor'] += count( $pairs ); $state['updated'] = time(); if ( count( $pairs ) < self::CHUNK ) { $state['status'] = 'done'; break; } if ( ! Repository::owns( $token, 'folder_import' ) ) { throw new \RuntimeException( 'folder_import_busy' ); } Reviewed_Store::update_option( self::OPTION, $state, false ); } while ( microtime( true ) < $until ); Reviewed_Store::update_option( self::OPTION, $state, false ); return $state; } finally { Repository::release( $token, 'folder_import' ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * What an import did, in words: folders made, folders already there and file placements done. * * @param array $s The import. * @return string The translated words. */ public static function message( array $s ): string { $name = self::sources()[ $s['source'] ]['name'] ?? ''; /* translators: %d: folders made by this import. */ $made = sprintf( _n( '%d folder made', '%d folders made', (int) $s['created'], 'affstackdev-image-manager' ), (int) $s['created'] ); /* translators: %d: folders this import found already there. */ $there = sprintf( __( '%d already there', 'affstackdev-image-manager' ), (int) $s['reused'] ); /* translators: 1: file placements done; 2: placements in total. */ $done = sprintf( _n( '%1$d of %2$d file placement done', '%1$d of %2$d file placements done', (int) $s['total'], 'affstackdev-image-manager' ), (int) $s['placed'], (int) $s['total'] ); /* translators: 1: other plugin's name; 2: folders made, such as "3 folders made"; 3: folders already there; 4: placements done, such as "40 of 40 file placements done". */ $text = sprintf( __( 'Import from %1$s: %2$s, %3$s; %4$s.', 'affstackdev-image-manager' ), $name, $made, $there, $done ); if ( $s['missing'] ) { /* translators: %d: placements whose file or folder no longer exists. */ $text .= ' ' . sprintf( _n( '%d placement was skipped because its file or folder no longer exists.', '%d placements were skipped because their file or folder no longer exists.', (int) $s['missing'], 'affstackdev-image-manager' ), (int) $s['missing'] ); } $text .= ' ' . ( 'done' === $s['status'] ? __( 'The import is finished. The other plugin’s folders are unchanged.', 'affstackdev-image-manager' ) : __( 'More remains: choose Continue import.', 'affstackdev-image-manager' ) ); return $text; } /** * The words for an import's reason code. * * @param string $code The reason code. * @return string The translated words. */ public static function human_error( string $code ): string { $m = array( 'folder_import_access' => __( 'You cannot organize site media.', 'affstackdev-image-manager' ), 'folder_import_source' => __( 'No folders were found in that plugin. Reload the library.', 'affstackdev-image-manager' ), 'folder_import_confirm' => __( 'Tick the confirmation box, then import again. Nothing was changed.', 'affstackdev-image-manager' ), 'folder_import_busy' => __( 'An import is still working. Reload the library in a moment.', 'affstackdev-image-manager' ), 'folder_import_other' => __( 'Another import is not finished. Continue it first.', 'affstackdev-image-manager' ), 'folder_import_stale' => __( 'This import finished or changed in another window. Reload the library.', 'affstackdev-image-manager' ), 'folder_import_too_many' => __( 'That plugin has more than 5,000 folders, more than one import takes. Nothing was changed.', 'affstackdev-image-manager' ), 'folder_import_failed' => __( 'A folder could not be made or filled. What was done so far is kept; reload the library and continue.', 'affstackdev-image-manager' ), ); return $m[ $code ] ?? __( 'The import could not finish. What was done so far is kept; reload the library.', 'affstackdev-image-manager' ); } }