'upload_files', 'edit_terms' => 'upload_files', 'delete_terms' => 'upload_files', 'assign_terms' => 'upload_files', ); register_taxonomy( self::FOLDER, array( 'attachment' ), array( 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'hierarchical' => true, 'query_var' => false, 'rewrite' => false, 'capabilities' => $caps, 'labels' => array( 'name' => __( 'Media folders', 'affstackdev-image-manager' ), 'singular_name' => __( 'Media folder', 'affstackdev-image-manager' ), ), ) ); register_taxonomy( self::COLLECTION, array( 'attachment' ), array( 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'hierarchical' => false, 'query_var' => false, 'rewrite' => false, 'capabilities' => $caps, 'labels' => array( 'name' => __( 'Media collections', 'affstackdev-image-manager' ), 'singular_name' => __( 'Media collection', 'affstackdev-image-manager' ), ), ) ); } /** * Every folder or every collection (read once per request). * * @param string $taxonomy afsd_im_folder or afsd_im_collection. * @return array The terms, or an empty array for another taxonomy. */ public static function terms( string $taxonomy ): array { if ( ! in_array( $taxonomy, array( self::FOLDER, self::COLLECTION ), true ) ) { return array(); } if ( array_key_exists( $taxonomy, self::$term_cache ) ) { return self::$term_cache[ $taxonomy ]; } $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', ) ); self::$term_cache[ $taxonomy ] = is_wp_error( $terms ) ? array() : $terms; return self::$term_cache[ $taxonomy ]; } /** * Fetch only Image Manager organization relationships for a visible attachment page. * * @param array $attachment_ids The attachments shown. * @return array Attachment => its folders and collections. */ public static function organization_for_objects( array $attachment_ids ): array { $ids = array_values( array_unique( array_filter( array_map( 'absint', $attachment_ids ) ) ) ); $out = array(); foreach ( $ids as $id ) { $out[ $id ] = array( 'folders' => array(), 'collections' => array(), ); } if ( ! $ids ) { return $out; } $terms = wp_get_object_terms( $ids, array( self::FOLDER, self::COLLECTION ), array( 'fields' => 'all_with_object_id' ) ); if ( is_wp_error( $terms ) ) { return $out; } foreach ( $terms as $term ) { $object_id = isset( $term->object_id ) ? absint( $term->object_id ) : 0; if ( ! $object_id || ! isset( $out[ $object_id ] ) ) { continue; } $bucket = self::FOLDER === $term->taxonomy ? 'folders' : ( self::COLLECTION === $term->taxonomy ? 'collections' : '' ); if ( $bucket ) { $out[ $object_id ][ $bucket ][] = (string) $term->name; } } return $out; } /** * The folders or collections an attachment is in. * * @param int $attachment The attachment. * @param string $taxonomy afsd_im_folder or afsd_im_collection. * @return array Term IDs. */ public static function term_ids( int $attachment, string $taxonomy ): array { $ids = wp_get_object_terms( $attachment, $taxonomy, array( 'fields' => 'ids' ) ); return is_wp_error( $ids ) ? array() : array_map( 'intval', $ids ); } /** * Make a folder or collection, for people who may upload files. * * @param string $taxonomy afsd_im_folder or afsd_im_collection. * @param string $name Its name. * @param int $parent The parent folder (0 for the top). * @return array|\WP_Error The new term's IDs, or why it was not made. */ public static function create_term( string $taxonomy, string $name, int $parent = 0 ) { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! current_user_can( 'upload_files' ) ) { return new \WP_Error( 'forbidden', __( 'You cannot organize media.', 'affstackdev-image-manager' ) ); } if ( ! in_array( $taxonomy, array( self::FOLDER, self::COLLECTION ), true ) ) { return new \WP_Error( 'invalid_taxonomy', __( 'Invalid organization type.', 'affstackdev-image-manager' ) ); } $name = trim( sanitize_text_field( $name ) ); if ( '' === $name || strlen( $name ) > 120 ) { return new \WP_Error( 'invalid_name', __( 'Enter a folder or collection name.', 'affstackdev-image-manager' ) ); } $args = array(); if ( self::FOLDER === $taxonomy && $parent > 0 ) { $term = get_term( $parent, $taxonomy ); if ( $term && ! is_wp_error( $term ) ) { $args['parent'] = $parent; } } $result = Reviewed_Store::wp_insert_term( $name, $taxonomy, $args ); if ( ! is_wp_error( $result ) ) { unset( self::$term_cache[ $taxonomy ] ); } return $result; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Put attachments in folders or collections. * * @param array $ids The attachments. * @param string $taxonomy afsd_im_folder or afsd_im_collection. * @param array $term_ids The terms. * @param bool $replace True to replace their terms of that taxonomy, false to add. * @return int How many attachments changed. * @throws \RuntimeException With invalid_media_taxonomy for another taxonomy. */ public static function assign( array $ids, string $taxonomy, array $term_ids, bool $replace = false ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! in_array( $taxonomy, array( self::FOLDER, self::COLLECTION ), true ) ) { throw new \RuntimeException( 'invalid_media_taxonomy' ); } $term_ids = array_values( array_unique( array_filter( array_map( 'absint', $term_ids ) ) ) ); $changed = 0; foreach ( $ids as $id ) { $id = absint( $id ); if ( ! $id || 'attachment' !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) ) { continue; } $result = Reviewed_Store::wp_set_object_terms( $id, $term_ids, $taxonomy, ! $replace ); if ( ! is_wp_error( $result ) ) { ++$changed; } } return $changed; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Take attachments out of folders or collections. * * @param array $ids The attachments. * @param string $taxonomy afsd_im_folder or afsd_im_collection. * @param array $term_ids The terms. * @return int How many attachments changed. * @throws \RuntimeException With invalid_media_taxonomy for another taxonomy. */ public static function detach( array $ids, string $taxonomy, array $term_ids ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! in_array( $taxonomy, array( self::FOLDER, self::COLLECTION ), true ) ) { throw new \RuntimeException( 'invalid_media_taxonomy' ); } $term_ids = array_values( array_unique( array_filter( array_map( 'absint', $term_ids ) ) ) ); $changed = 0; foreach ( $ids as $id ) { $id = absint( $id ); if ( ! $id || 'attachment' !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) ) { continue; } $result = Reviewed_Store::wp_remove_object_terms( $id, $term_ids, $taxonomy ); if ( ! is_wp_error( $result ) ) { ++$changed; } } return $changed; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Set the title, caption or alt text (images only) of attachments in bulk. * * @param array $ids The attachments. * @param array $data Which fields to set (set_title, set_caption, set_alt) and their text. * @return int How many attachments changed. * @throws \RuntimeException With choose_metadata_field when no field is chosen. */ public static function update_metadata( array $ids, array $data ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $set_title = ! empty( $data['set_title'] ); $set_caption = ! empty( $data['set_caption'] ); $set_alt = ! empty( $data['set_alt'] ); $title = isset( $data['media_title'] ) && is_string( $data['media_title'] ) ? sanitize_text_field( $data['media_title'] ) : ''; $caption = isset( $data['media_caption'] ) && is_string( $data['media_caption'] ) ? sanitize_textarea_field( $data['media_caption'] ) : ''; $alt = isset( $data['media_alt'] ) && is_string( $data['media_alt'] ) ? sanitize_text_field( $data['media_alt'] ) : ''; if ( ! $set_title && ! $set_caption && ! $set_alt ) { throw new \RuntimeException( 'choose_metadata_field' ); } $changed = 0; foreach ( $ids as $id ) { $id = absint( $id ); if ( ! $id || 'attachment' !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) ) { continue; } $post = array( 'ID' => $id ); if ( $set_title ) { $post['post_title'] = $title; } if ( $set_caption ) { $post['post_excerpt'] = $caption; } if ( count( $post ) > 1 ) { $result = Reviewed_Store::wp_update_post( wp_slash( $post ), true ); if ( is_wp_error( $result ) ) { continue; } } if ( $set_alt && str_starts_with( (string) get_post_mime_type( $id ), 'image/' ) ) { Reviewed_Store::update_post_meta( $id, '_wp_attachment_image_alt', $alt ); } ++$changed; } return $changed; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Folder and collection fields on the attachment edit screen and in the media modal. * * @param array $form_fields WordPress's attachment fields. * @param mixed $post The attachment. * @return array The fields. */ public static function fields( array $form_fields, $post ): array { if ( ! $post || 'attachment' !== $post->post_type || ! current_user_can( 'edit_post', (int) $post->ID ) ) { return $form_fields; } $folder_ids = self::term_ids( (int) $post->ID, self::FOLDER ); $collection_ids = self::term_ids( (int) $post->ID, self::COLLECTION ); $folders = self::terms( self::FOLDER ); $collections = self::terms( self::COLLECTION ); $folder_html = ''; $collection_html = '
'; foreach ( $collections as $term ) { $collection_html .= ''; } $collection_html .= $collections ? '
' : '' . esc_html__( 'Create collections in AffStackDev → Image Manager → Media.', 'affstackdev-image-manager' ) . ''; $form_fields['afsd_im_folder'] = array( 'label' => __( 'Image Manager folder', 'affstackdev-image-manager' ), 'input' => 'html', 'html' => $folder_html, 'helps' => __( 'Virtual only. The file path and URL do not move.', 'affstackdev-image-manager' ), ); $form_fields['afsd_im_collections'] = array( 'label' => __( 'Image Manager collections', 'affstackdev-image-manager' ), 'input' => 'html', 'html' => $collection_html, 'helps' => __( 'An item can belong to multiple collections.', 'affstackdev-image-manager' ), ); return $form_fields; } /** * Save the attachment screen's folder and collection fields. * * @param array $post The attachment's post data. * @param array $attachment The submitted attachment fields. * @return array The post data. * @throws \RuntimeException What a step inside threw, passed on after cleaning up. */ public static function save_fields( array $post, array $attachment ): array { try { $im_reviewed_owner_lock = Reviewed_Store::enter( 8 ); } catch ( \RuntimeException $e ) { if ( ! Reviewed_Store::busy( $e ) ) { throw $e; } // The native attachment save continues; only these Image Manager fields wait for a later save. $post['errors']['afsd_im_folder']['errors'][] = __( 'Image Manager is finishing background work, so folder and collection changes were not saved. Save again in a moment.', 'affstackdev-image-manager' ); return $post; } try { $id = absint( $post['ID'] ?? 0 ); if ( ! $id || ! current_user_can( 'edit_post', $id ) ) { return $post; } if ( array_key_exists( 'afsd_im_folder', $attachment ) ) { $folder = absint( $attachment['afsd_im_folder'] ); Reviewed_Store::wp_set_object_terms( $id, $folder ? array( $folder ) : array(), self::FOLDER, false ); } if ( array_key_exists( 'afsd_im_collections', $attachment ) ) { $collections = is_array( $attachment['afsd_im_collections'] ) ? array_map( 'absint', $attachment['afsd_im_collections'] ) : array(); Reviewed_Store::wp_set_object_terms( $id, array_values( array_filter( $collections ) ), self::COLLECTION, false ); } return $post; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Filter the media modal's query by the folder or collection chosen there. * * @param array $query WordPress's attachment query. * @return array The query. */ public static function query_args( array $query ): array { if ( ! current_user_can( 'upload_files' ) ) { return $query; } $folder = isset( $_REQUEST['query']['afsd_im_folder'] ) ? absint( $_REQUEST['query']['afsd_im_folder'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only media-modal filter. $collection = isset( $_REQUEST['query']['afsd_im_collection'] ) ? absint( $_REQUEST['query']['afsd_im_collection'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tax = array(); if ( $folder ) { $tax[] = array( 'taxonomy' => self::FOLDER, 'field' => 'term_id', 'terms' => array( $folder ), ); } if ( $collection ) { $tax[] = array( 'taxonomy' => self::COLLECTION, 'field' => 'term_id', 'terms' => array( $collection ), ); } // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. if ( $tax ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. $query['tax_query'] = isset( $query['tax_query'] ) && is_array( $query['tax_query'] ) ? array_merge( $query['tax_query'], $tax ) : $tax; } return $query; } /** * Load the media modal's folder and collection filters on the Media Library and post screens. * * @param string $hook The admin screen. */ public static function modal_assets( string $hook ): void { if ( ! current_user_can( 'upload_files' ) ) { return; } if ( ! in_array( $hook, array( 'upload.php', 'post.php', 'post-new.php' ), true ) ) { return; } wp_enqueue_media(); wp_enqueue_script( 'afsd-im-media-modal', plugins_url( 'assets/media-modal.js', FILE ), array( 'media-views' ), VERSION, true ); wp_localize_script( 'afsd-im-media-modal', 'afsdIMMedia', array( 'folders' => array_map( static fn( $t ) => array( 'id' => (int) $t->term_id, 'name' => $t->name, ), self::terms( self::FOLDER ) ), 'collections' => array_map( static fn( $t ) => array( 'id' => (int) $t->term_id, 'name' => $t->name, ), self::terms( self::COLLECTION ) ), 'folderLabel' => __( 'All folders', 'affstackdev-image-manager' ), 'collectionLabel' => __( 'All collections', 'affstackdev-image-manager' ), ) ); } /** * Delete or rename a folder or collection, for people who may upload files. * * @param string $taxonomy afsd_im_folder or afsd_im_collection. * @param int $term_id The term. * @param string $action delete or rename. * @param string $name The new name, for rename. * @return bool True when it was changed. */ public static function change_term( string $taxonomy, int $term_id, string $action, string $name = '' ): bool { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! current_user_can( 'upload_files' ) || ! in_array( $taxonomy, array( self::FOLDER, self::COLLECTION ), true ) || $term_id < 1 ) { return false; } $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { return false; } if ( 'delete' === $action ) { $result = Reviewed_Store::wp_delete_term( $term_id, $taxonomy ); if ( ! is_wp_error( $result ) && false !== $result ) { unset( self::$term_cache[ $taxonomy ] ); return true; } return false; } if ( 'rename' === $action ) { $name = trim( sanitize_text_field( $name ) ); if ( '' === $name || strlen( $name ) > 120 ) { return false; } $result = Reviewed_Store::wp_update_term( $term_id, $taxonomy, array( 'name' => $name ) ); if ( ! is_wp_error( $result ) ) { unset( self::$term_cache[ $taxonomy ] ); return true; } return false; } return false; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } }