each): a * folder bar, a Folder column, the bulk actions Move to folder and Take out of its folder, and Manage folders. Nothing * public changes. */ final class Content_Folders { public const PREFIX = 'afsd_im_cf_'; public const QUERY = 'afsd_im_cfolder'; private const NOTICE = 'afsd_im_cf_notice_'; private const MOVE = 'afsd_im_cf_move_'; private const REMOVE = 'afsd_im_cf_remove'; public const MAX_FOLDERS = 500; public const MAX_ITEMS = 999; // WordPress's own largest list page. /** WordPress's and WooCommerce's internal types, and Media (which has Image Manager's own folders). */ private const EXCLUDED = array( 'attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_font_family', 'wp_font_face', 'shop_order', 'shop_order_refund', 'shop_order_placehold', 'scheduled-action' ); /** * Folders read in this request, per post type. * * @var array */ private static array $cache = array(); /** * The folder taxonomy of a post type. * * @param string $type The post type. * @return string afsd_im_cf_ and the post type. */ public static function taxonomy( string $type ): string { return self::PREFIX . $type; } /** Post types that have an admin list and can take folders, as label by name. */ public static function available(): array { $out = array(); foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $name => $object ) { if ( in_array( $name, self::EXCLUDED, true ) || strlen( self::taxonomy( $name ) ) > 32 || ! preg_match( '/^[a-z0-9_-]+$/D', $name ) ) { continue; } if ( function_exists( 'wc_get_order_types' ) && in_array( $name, wc_get_order_types(), true ) ) { continue; } $out[ $name ] = (string) $object->labels->name; } return $out; } /** Switched-on types that exist on this request. */ public static function enabled(): array { $saved = Settings::get()['content_folders']; return array_values( array_filter( is_array( $saved ) ? $saved : array(), static fn( $type ) => is_string( $type ) && post_type_exists( $type ) && taxonomy_exists( self::taxonomy( $type ) ) ) ); } /** * The chosen post types kept to those that can take folders. * * @param mixed $types The post types asked for. * @return array The post types. */ public static function clean_types( $types ): array { $available = self::available(); $out = array(); foreach ( is_array( $types ) ? $types : array() as $type ) { if ( is_string( $type ) && isset( $available[ $type ] ) ) { $out[] = $type; } } return array_values( array_unique( $out ) ); } /** * Register a private, hierarchical folder taxonomy for each chosen post type that exists and can take folders. */ public static function register(): void { $saved = Settings::get()['content_folders']; foreach ( is_array( $saved ) ? $saved : array() as $type ) { if ( ! is_string( $type ) || ! post_type_exists( $type ) || in_array( $type, self::EXCLUDED, true ) || strlen( self::taxonomy( $type ) ) > 32 ) { continue; } $cap = get_post_type_object( $type )->cap; register_taxonomy( self::taxonomy( $type ), array( $type ), array( 'public' => false, 'publicly_queryable' => false, 'show_ui' => false, 'show_in_rest' => false, 'show_in_nav_menus' => false, 'show_tagcloud' => false, 'show_admin_column' => false, 'hierarchical' => true, 'query_var' => false, 'rewrite' => false, 'capabilities' => array( 'manage_terms' => $cap->edit_others_posts, 'edit_terms' => $cap->edit_others_posts, 'delete_terms' => $cap->edit_others_posts, 'assign_terms' => $cap->edit_posts, ), 'labels' => array( 'name' => __( 'Folders', 'affstackdev-image-manager' ), 'singular_name' => __( 'Folder', 'affstackdev-image-manager' ), ), ) ); } } /** * Whether the current user may manage a type's folders (they may edit others' items). * * @param string $type The post type. * @return bool True when they may. */ public static function can_manage( string $type ): bool { $object = get_post_type_object( $type ); return $object && current_user_can( $object->cap->edit_others_posts ); } /** * Whether the current user may move items of a type (they may edit such items). * * @param string $type The post type. * @return bool True when they may. */ public static function can_use( string $type ): bool { $object = get_post_type_object( $type ); return $object && current_user_can( $object->cap->edit_posts ); } /** * All folders of a type, ordered as a tree: each with id, name, parent, depth and path. * * @param string $type The post type. * @return array The folders. */ public static function folders( string $type ): array { if ( isset( self::$cache[ $type ] ) ) { return self::$cache[ $type ]; } $terms = taxonomy_exists( self::taxonomy( $type ) ) ? get_terms( array( 'taxonomy' => self::taxonomy( $type ), 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', 'number' => self::MAX_FOLDERS, ) ) : array(); $children = array(); $byid = array(); foreach ( is_array( $terms ) ? $terms : array() as $t ) { $byid[ (int) $t->term_id ] = $t; } foreach ( $byid as $id => $t ) { $parent = isset( $byid[ (int) $t->parent ] ) ? (int) $t->parent : 0; $children[ $parent ][] = $id; } $out = array(); $walk = static function ( int $parent, int $depth, string $path ) use ( &$walk, &$out, $children, $byid ): void { foreach ( $children[ $parent ] ?? array() as $id ) { $name = (string) $byid[ $id ]->name; $full = '' === $path ? $name : $path . ' › ' . $name; $out[ $id ] = array( 'id' => $id, 'name' => $name, 'parent' => $parent, 'depth' => $depth, 'path' => $full, ); if ( $depth < 20 ) { $walk( $id, $depth + 1, $full ); } } }; $walk( 0, 0, '' ); return self::$cache[ $type ] = $out; } /** * Forget the folders read in this request. */ public static function forget(): void { self::$cache = array(); } /** * Item counts per folder, all items and items in no folder. The trash and auto-drafts do not count. * * @param string $type The post type. * @return array Folder => count, with all and none. */ public static function counts( string $type ): array { global $wpdb; $tax = self::taxonomy( $type ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Folder counts for the list screen in one grouped query; term counts of WordPress include the trash and cannot exclude it. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT tt.term_id, COUNT(DISTINCT p.ID) AS n FROM %i tr INNER JOIN %i tt ON tt.term_taxonomy_id=tr.term_taxonomy_id INNER JOIN %i p ON p.ID=tr.object_id WHERE tt.taxonomy=%s AND p.post_type=%s AND p.post_status NOT IN ('trash','auto-draft','inherit') GROUP BY tt.term_id", $wpdb->term_relationships, $wpdb->term_taxonomy, $wpdb->posts, $tax, $type ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Same count basis as the folder counts above. $all = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_type=%s AND post_status NOT IN ('trash','auto-draft','inherit')", $wpdb->posts, $type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Same count basis as the folder counts above. $filed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM %i tr INNER JOIN %i tt ON tt.term_taxonomy_id=tr.term_taxonomy_id INNER JOIN %i p ON p.ID=tr.object_id WHERE tt.taxonomy=%s AND p.post_type=%s AND p.post_status NOT IN ('trash','auto-draft','inherit')", $wpdb->term_relationships, $wpdb->term_taxonomy, $wpdb->posts, $tax, $type ) ); $out = array( 'all' => $all, 'none' => max( 0, $all - $filed ), 'folders' => array(), ); foreach ( is_array( $rows ) ? $rows : array() as $r ) { $out['folders'][ (int) $r['term_id'] ] = (int) $r['n']; } return $out; } /** * The folder an item is in. * * @param int $post_id The item. * @param string $type Its post type. * @return int The folder's ID, or 0. */ public static function folder_of( int $post_id, string $type ): int { $ids = wp_get_object_terms( $post_id, self::taxonomy( $type ), array( 'fields' => 'ids' ) ); return is_array( $ids ) && $ids ? (int) $ids[0] : 0; } /* ---- Writers: owner lock, rights checked here, writes through Reviewed_Store. ---- */ /** * Make a folder, for someone who may manage the type's folders. * * @param string $type The post type. * @param string $name Its name. * @param int $parent The parent folder (0 for the top). * @return int The new folder's ID. * @throws \RuntimeException With a content_folder_ reason code when it cannot be made. */ public static function create( string $type, string $name, int $parent = 0 ): int { $held = Reviewed_Store::enter( 8 ); try { self::require_type( $type ); if ( ! self::can_manage( $type ) ) { throw new \RuntimeException( 'content_folder_forbidden' ); } $name = self::clean_name( $name ); if ( count( self::folders( $type ) ) >= self::MAX_FOLDERS ) { throw new \RuntimeException( 'content_folder_limit' ); } if ( $parent && ! isset( self::folders( $type )[ $parent ] ) ) { throw new \RuntimeException( 'content_folder_missing' ); } foreach ( self::folders( $type ) as $f ) { if ( $f['parent'] === $parent && 0 === strcasecmp( $f['name'], $name ) ) { throw new \RuntimeException( 'content_folder_exists' ); } } $result = Reviewed_Store::wp_insert_term( $name, self::taxonomy( $type ), array( 'parent' => $parent, 'slug' => 'f-' . strtolower( wp_generate_password( 10, false, false ) ), ) ); self::forget(); if ( is_wp_error( $result ) ) { throw new \RuntimeException( 'content_folder_failed' ); } return (int) $result['term_id']; } finally { Reviewed_Store::leave( $held ); } } /** * Rename a folder. * * @param string $type The post type. * @param int $id The folder. * @param string $name The new name. * @throws \RuntimeException With a content_folder_ reason code when it cannot be renamed. */ public static function rename( string $type, int $id, string $name ): void { $held = Reviewed_Store::enter( 8 ); try { self::require_folder( $type, $id ); $name = self::clean_name( $name ); $f = self::folders( $type )[ $id ]; foreach ( self::folders( $type ) as $other ) { if ( $other['id'] !== $id && $other['parent'] === $f['parent'] && 0 === strcasecmp( $other['name'], $name ) ) { throw new \RuntimeException( 'content_folder_exists' ); } } $result = Reviewed_Store::wp_update_term( $id, self::taxonomy( $type ), array( 'name' => $name ) ); self::forget(); if ( is_wp_error( $result ) ) { throw new \RuntimeException( 'content_folder_failed' ); } } finally { Reviewed_Store::leave( $held ); } } /** * A folder moves under another one or to the top. Never into itself or one of its own sub-folders. * * @param string $type The post type. * @param int $id The folder. * @param int $parent The new parent (0 for the top). * @throws \RuntimeException With a content_folder_ reason code when it cannot be moved there. */ public static function nest( string $type, int $id, int $parent ): void { $held = Reviewed_Store::enter( 8 ); try { self::require_folder( $type, $id ); $folders = self::folders( $type ); if ( $parent && ! isset( $folders[ $parent ] ) ) { throw new \RuntimeException( 'content_folder_missing' ); } for ( $p = $parent, $guard = 0; $p && $guard < 50; $p = $folders[ $p ]['parent'] ?? 0, $guard++ ) { if ( $p === $id ) { throw new \RuntimeException( 'content_folder_cycle' ); } } foreach ( $folders as $other ) { if ( $other['id'] !== $id && $other['parent'] === $parent && 0 === strcasecmp( $other['name'], $folders[ $id ]['name'] ) ) { throw new \RuntimeException( 'content_folder_exists' ); } } $result = Reviewed_Store::wp_update_term( $id, self::taxonomy( $type ), array( 'parent' => $parent ) ); self::forget(); if ( is_wp_error( $result ) ) { throw new \RuntimeException( 'content_folder_failed' ); } } finally { Reviewed_Store::leave( $held ); } } /** * WordPress moves the sub-folders up to this folder's parent; the items only lose this folder. * * @param string $type The post type. * @param int $id The folder. * @throws \RuntimeException With a content_folder_ reason code when it cannot be deleted. */ public static function delete( string $type, int $id ): void { $held = Reviewed_Store::enter( 8 ); try { self::require_folder( $type, $id ); $result = Reviewed_Store::wp_delete_term( $id, self::taxonomy( $type ) ); self::forget(); if ( true !== $result ) { throw new \RuntimeException( 'content_folder_failed' ); } } finally { Reviewed_Store::leave( $held ); } } /** * Put items into one folder (0: take them out of their folder). Returns how many items now are where asked. * * @param string $type The post type. * @param array $ids The items. * @param int $folder The folder, or 0. * @return int How many items are where asked. * @throws \RuntimeException With a content_folder_ reason code when the items cannot be moved. */ public static function move( string $type, array $ids, int $folder ): int { $held = Reviewed_Store::enter( 8 ); try { self::require_type( $type ); if ( ! self::can_use( $type ) ) { throw new \RuntimeException( 'content_folder_forbidden' ); } if ( $folder && ! isset( self::folders( $type )[ $folder ] ) ) { throw new \RuntimeException( 'content_folder_missing' ); } $ids = array_slice( array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ), 0, self::MAX_ITEMS ); if ( ! $ids ) { throw new \RuntimeException( 'content_folder_no_items' ); } $done = 0; foreach ( $ids as $id ) { if ( get_post_type( $id ) !== $type || ! current_user_can( 'edit_post', $id ) ) { continue; } $result = Reviewed_Store::wp_set_object_terms( $id, $folder ? array( $folder ) : array(), self::taxonomy( $type ), false ); if ( ! is_wp_error( $result ) && self::folder_of( $id, $type ) === $folder ) { ++$done; } } return $done; } finally { Reviewed_Store::leave( $held ); } } /** * Check a post type has folders switched on. * * @param string $type The post type. * @throws \RuntimeException With content_folder_type_off when it has not. */ private static function require_type( string $type ): void { if ( ! in_array( $type, self::enabled(), true ) ) { throw new \RuntimeException( 'content_folder_type_off' ); } } /** * Check the current user may manage the type's folders and the folder exists. * * @param string $type The post type. * @param int $id The folder. * @throws \RuntimeException With a content_folder_ reason code when not. */ private static function require_folder( string $type, int $id ): void { self::require_type( $type ); if ( ! self::can_manage( $type ) ) { throw new \RuntimeException( 'content_folder_forbidden' ); } if ( ! isset( self::folders( $type )[ $id ] ) ) { throw new \RuntimeException( 'content_folder_missing' ); } } /** * A folder name as plain text, at most 120 characters. * * @param string $name The name asked for. * @return string The name. * @throws \RuntimeException With content_folder_name for an empty or too long name. */ private static function clean_name( string $name ): string { $name = trim( sanitize_text_field( $name ) ); if ( '' === $name || strlen( $name ) > 120 ) { throw new \RuntimeException( 'content_folder_name' ); } return $name; } /** * The words for a folder reason code. * * @param string $code The reason code. * @return string The translated words. */ public static function human_error( string $code ): string { $messages = array( 'content_folder_forbidden' => __( 'You cannot change these folders.', 'affstackdev-image-manager' ), 'content_folder_type_unknown' => __( 'One of those list types does not exist on this site or cannot take folders.', 'affstackdev-image-manager' ), 'content_folder_type_off' => __( 'Folders are not switched on for this list. Switch them on in Image Manager: Settings, Folders for pages and posts.', 'affstackdev-image-manager' ), 'content_folder_missing' => __( 'That folder no longer exists. Reload the list and try again.', 'affstackdev-image-manager' ), 'content_folder_exists' => __( 'A folder with that name is already in the same place. Choose another name.', 'affstackdev-image-manager' ), 'content_folder_name' => __( 'Enter a folder name of at most 120 characters.', 'affstackdev-image-manager' ), 'content_folder_limit' => __( 'This list already has 500 folders, the most Image Manager keeps for one list.', 'affstackdev-image-manager' ), 'content_folder_cycle' => __( 'A folder cannot go into itself or into one of its own sub-folders.', 'affstackdev-image-manager' ), 'content_folder_no_items' => __( 'Check at least one item first.', 'affstackdev-image-manager' ), 'content_folder_confirm' => __( 'Tick the confirmation box, then delete again. Nothing was changed.', 'affstackdev-image-manager' ), 'worker_busy' => __( 'Image Manager is finishing background work. Try again in a moment. Nothing was changed.', 'affstackdev-image-manager' ), ); return $messages[ $code ] ?? __( 'The folders could not be changed. Reload the list and try again.', 'affstackdev-image-manager' ); } /* ---- The list screen (edit.php). Reads only; every change goes through a form, a bulk action or the move request. ---- */ /** * The post type of the admin list being shown, when it has folders. * * @return string The post type, or an empty string. */ private static function screen_type(): string { global $typenow; $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( $screen && 'edit' === $screen->base && is_string( $screen->post_type ) && '' !== $screen->post_type ) { $type = $screen->post_type; } elseif ( is_string( $typenow ) && '' !== $typenow ) { $type = $typenow; } else { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the list screen's own post type, read only. $type = isset( $_GET['post_type'] ) && is_string( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : 'post'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the list screen's own post type, read only. return in_array( $type, self::enabled(), true ) && self::can_use( $type ) ? $type : ''; } /** * On a list with folders: filter its query by the chosen folder, and add the folder bar, the Folder column, the bulk * actions and the filter that keeps the folder. */ public static function load(): void { $type = self::screen_type(); if ( '' === $type ) { return; } add_action( 'pre_get_posts', array( self::class, 'filter_query' ) ); add_action( 'all_admin_notices', array( self::class, 'bar' ) ); add_action( 'restrict_manage_posts', array( self::class, 'keep_filter' ), 10, 2 ); add_filter( 'bulk_actions-edit-' . $type, array( self::class, 'bulk_actions' ) ); add_filter( 'handle_bulk_actions-edit-' . $type, array( self::class, 'bulk_handle' ), 10, 3 ); add_filter( 'manage_' . $type . '_posts_columns', array( self::class, 'columns' ) ); add_action( 'manage_' . $type . '_posts_custom_column', array( self::class, 'column' ), 10, 2 ); add_filter( 'removable_query_args', static fn( $args ) => array_merge( (array) $args, array( 'afsd_im_cf_done' ) ) ); add_action( 'admin_enqueue_scripts', static function () use ( $type ): void { wp_enqueue_style( 'afsd-im-content-folders', plugins_url( 'assets/content-folders.css', FILE ), array(), VERSION ); wp_enqueue_script( 'afsd-im-content-folders', plugins_url( 'assets/content-folders.js', FILE ), array(), VERSION, true ); wp_localize_script( 'afsd-im-content-folders', 'afsdIMContentFolders', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'afsd_im_cfolder_' . $type ), 'type' => $type, 'drag' => __( 'Drag onto a folder', 'affstackdev-image-manager' ), /* translators: %d: number of items being moved. */ 'moving' => __( 'Moving %d item(s)…', 'affstackdev-image-manager' ), 'failed' => __( 'The items could not be moved. Reload the list and try again.', 'affstackdev-image-manager' ), ) ); } ); } /** * The folder chosen in the list's address. * * @return string none, a folder ID, or an empty string for all. */ private static function current(): string { $raw = isset( $_GET[ self::QUERY ] ) && is_string( $_GET[ self::QUERY ] ) ? sanitize_key( wp_unslash( $_GET[ self::QUERY ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list filter. return 'none' === $raw || ctype_digit( $raw ) ? $raw : ''; } /** * Show only the chosen folder's items (or those in no folder) in the list's main query. * * @param mixed $query The query. */ public static function filter_query( $query ): void { if ( ! $query instanceof \WP_Query || ! $query->is_main_query() || ! is_admin() ) { return; } $type = self::screen_type(); $current = self::current(); if ( '' === $type || '' === $current ) { return; } $clause = 'none' === $current ? array( 'taxonomy' => self::taxonomy( $type ), 'operator' => 'NOT EXISTS', ) : array( 'taxonomy' => self::taxonomy( $type ), 'field' => 'term_id', 'terms' => array( (int) $current ), 'include_children' => false, ); $existing = $query->get( 'tax_query' ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- The admin list of one post type filtered by the folder the user chose. $query->set( 'tax_query', array_merge( is_array( $existing ) ? $existing : array(), array( $clause ) ) ); } /** * The list's own filter form keeps the chosen folder. * * @param mixed $post_type The list's post type. * @param mixed $which top or bottom (the field is added once, at the top). */ public static function keep_filter( $post_type, $which = 'top' ): void { $current = self::current(); if ( 'top' === $which && '' !== $current ) { echo ''; } } /** * The address of a list, with a folder chosen. * * @param string $type The post type. * @param string $folder The folder, none, or an empty string for all. * @return string The address. */ private static function list_url( string $type, string $folder = '' ): string { $base = 'post' === $type ? admin_url( 'edit.php' ) : add_query_arg( 'post_type', $type, admin_url( 'edit.php' ) ); return '' === $folder ? $base : add_query_arg( self::QUERY, $folder, $base ); } /** * Print the folder bar above the list: All, the folders with their counts, Not in a folder and Manage folders. */ public static function bar(): void { $type = self::screen_type(); if ( '' === $type ) { return; } $folders = self::folders( $type ); $counts = self::counts( $type ); $current = self::current(); $label = (string) get_post_type_object( $type )->labels->name; $notice = get_transient( self::NOTICE . get_current_user_id() ); if ( is_string( $notice ) ) { Reviewed_Store::delete_transient( self::NOTICE . get_current_user_id() ); } echo ''; } /** * Print one link of the folder bar. * * @param string $type The post type. * @param string $folder The folder, none, or an empty string for all. * @param string $text The link's text. * @param bool $active Whether it is the folder shown. * @param int $depth Its depth in the tree. * @param int $drop Where items dropped on it go: a folder ID, -1 for no folder, 0 when nothing may be dropped on * it. */ private static function link( string $type, string $folder, string $text, bool $active, int $depth, int $drop ): void { echo '