fetcher = new PostFetcher(); } /** * Creates a new post. * * ## OPTIONS * * [--post_author=] * : The ID of the user who added the post. Default is the current user ID. * * [--post_date=] * : The date of the post. Default is the current time. * * [--post_date_gmt=] * : The date of the post in the GMT timezone. Default is the value of $post_date. * * [--post_content=] * : The post content. Default empty. * * [--post_content_filtered=] * : The filtered post content. Default empty. * * [--post_title=] * : The post title. Default empty. * * [--post_excerpt=] * : The post excerpt. Default empty. * * [--post_status=] * : The post status. Default 'draft'. * * [--post_type=] * : The post type. Default 'post'. * * [--comment_status=] * : Whether the post can accept comments. Accepts 'open' or 'closed'. Default is the value of 'default_comment_status' option. * * [--ping_status=] * : Whether the post can accept pings. Accepts 'open' or 'closed'. Default is the value of 'default_ping_status' option. * * [--post_password=] * : The password to access the post. Default empty. * * [--post_name=] * : The post name. Default is the sanitized post title when creating a new post. * * [--from-post=] * : Post id of a post to be duplicated. * * [--to_ping=] * : Space or carriage return-separated list of URLs to ping. Default empty. * * [--pinged=] * : Space or carriage return-separated list of URLs that have been pinged. Default empty. * * [--post_modified=] * : The date when the post was last modified. Default is the current time. * * [--post_modified_gmt=] * : The date when the post was last modified in the GMT timezone. Default is the current time. * * [--post_parent=] * : Set this for the post it belongs to, if any. Default 0. * * [--menu_order=] * : The order the post should be displayed in. Default 0. * * [--post_mime_type=] * : The mime type of the post. Default empty. * * [--guid=] * : Global Unique ID for referencing the post. Default empty. * * [--post_category=] * : Array of category names, slugs, or IDs. Defaults to value of the 'default_category' option. * * [--tags_input=] * : Array of tag names, slugs, or IDs. Default empty. * * [--tax_input=] * : Array of taxonomy terms keyed by their taxonomy name. Default empty. * * Note: In WordPress core, this normally requires a user context to satisfy capability checks. WP-CLI bypasses this for convenience. See https://core.trac.wordpress.org/ticket/19373 * * [--meta_input=] * : Array in JSON format of post meta values keyed by their post meta key. Default empty. * * [--page_template=] * : Page template to use. * * [--import_id=] * : The post ID to be used when inserting a new post. If specified, must not * match any existing post ID. * * [] * : Read post content from . If this value is present, the * `--post_content` argument will be ignored. * * Passing `-` as the filename will cause post content to * be read from STDIN. * * [--=] * : Associative args for the new post. See wp_insert_post(). * * [--edit] * : Immediately open system's editor to write or edit post content. * * If content is read from a file, from STDIN, or from the `--post_content` * argument, that text will be loaded into the editor. * * [--porcelain] * : Output just the new post id. * * * ## EXAMPLES * * # Create post and schedule for future * $ wp post create --post_type=post --post_title='A future post' --post_status=future --post_date='2030-12-01 07:00:00' * Success: Created post 1921. * * # Create post with content from given file * $ wp post create ./post-content.txt --post_category=201,345 --post_title='Post from file' * Success: Created post 1922. * * # Create a post with multiple meta values. * $ wp post create --post_title='A post' --post_content='Just a small post.' --meta_input='{"key1":"value1","key2":"value2"}' * Success: Created post 1923. * * # Create a duplicate post from existing posts. * $ wp post create --from-post=123 --post_title='Different Title' * Success: Created post 2350. */ public function create( $args, $assoc_args ) { if ( ! empty( $args[0] ) ) { $assoc_args['post_content'] = $this->read_from_file_or_stdin( $args[0] ); } if ( Utils\get_flag_value( $assoc_args, 'edit' ) ) { $input = Utils\get_flag_value( $assoc_args, 'post_content', '' ); $output = $this->_edit( $input, 'WP-CLI: New Post' ); if ( $output ) { $assoc_args['post_content'] = $output; } else { $assoc_args['post_content'] = $input; } } if ( isset( $assoc_args['post_category'] ) ) { $assoc_args['post_category'] = $this->get_category_ids( $assoc_args['post_category'] ); } $array_arguments = [ 'meta_input', 'tax_input' ]; $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); if ( isset( $assoc_args['from-post'] ) ) { $post = $this->fetcher->get_check( $assoc_args['from-post'] ); $post_arr = get_object_vars( $post ); $post_id = $post_arr['ID']; unset( $post_arr['post_date'] ); unset( $post_arr['post_date_gmt'] ); unset( $post_arr['post_modified'] ); unset( $post_arr['post_modified_gmt'] ); unset( $post_arr['guid'] ); unset( $post_arr['ID'] ); if ( empty( $assoc_args['meta_input'] ) ) { $assoc_args['meta_input'] = $this->get_metadata( $post_id ); } if ( empty( $assoc_args['post_category'] ) ) { $post_arr['post_category'] = $this->get_category( $post_id ); } if ( empty( $assoc_args['tags_input'] ) ) { $post_arr['tags_input'] = $this->get_tags( $post_id ); } $assoc_args = array_merge( $post_arr, $assoc_args ); } $assoc_args = wp_slash( $assoc_args ); parent::_create( $args, $assoc_args, function ( $params ) { $modified_callback = self::add_post_modified_filter( $params ); if ( is_wp_error( $modified_callback ) ) { return $modified_callback; } $filter_callback = null; if ( 0 === get_current_user_id() && ! empty( $params['tax_input'] ) ) { $allowed_caps = []; /** * @var string $taxonomy */ foreach ( array_keys( $params['tax_input'] ) as $taxonomy ) { $tax_obj = get_taxonomy( $taxonomy ); if ( $tax_obj ) { $primitive_caps = map_meta_cap( $tax_obj->cap->assign_terms, 0 ); $allowed_caps = array_merge( $allowed_caps, $primitive_caps ); } } if ( ! empty( $allowed_caps ) ) { $filter_callback = function ( $allcaps, $caps ) use ( $allowed_caps ) { foreach ( $caps as $cap ) { if ( in_array( $cap, $allowed_caps, true ) ) { $allcaps[ $cap ] = true; } } return $allcaps; }; add_filter( 'user_has_cap', $filter_callback, 10, 2 ); } } $result = wp_insert_post( $params, true ); if ( $modified_callback ) { self::remove_post_modified_filter( $modified_callback ); } if ( $filter_callback ) { remove_filter( 'user_has_cap', $filter_callback ); } return $result; } ); } /** * Applies an explicitly requested modification date. * * wp_insert_post() derives post_modified and post_modified_gmt itself and * never reads them back from $postarr — on update they are unconditionally * the current time — so the documented parameters have to be applied to the * post data on its way to the database. * * The filter runs after core has validated post_date, and both * get_gmt_from_date() and get_date_from_gmt() fall back to the epoch for a * value they cannot parse, so the supplied dates are validated here the same * way core validates post_date. * * @param array $params Parameters passed to wp_insert_post() or wp_update_post(). * @return callable|WP_Error|null The registered callback, for the caller to remove; a WP_Error * when a supplied date is invalid; or null when no modification * date was requested. */ private static function add_post_modified_filter( $params ) { $local = ! empty( $params['post_modified'] ) && is_scalar( $params['post_modified'] ) ? (string) $params['post_modified'] : null; $gmt = ! empty( $params['post_modified_gmt'] ) && is_scalar( $params['post_modified_gmt'] ) ? (string) $params['post_modified_gmt'] : null; if ( null === $local && null === $gmt ) { return null; } foreach ( [ $local, $gmt ] as $date ) { if ( null !== $date && ! self::is_valid_post_date( $date ) ) { return new WP_Error( 'invalid_date', __( 'Invalid date.' ) ); } } // Keep the pair consistent when only one of the two was given. if ( null === $gmt ) { $gmt = get_gmt_from_date( $local ); } elseif ( null === $local ) { $local = get_date_from_gmt( $gmt ); } $modified = [ 'post_modified' => $local, 'post_modified_gmt' => $gmt, ]; $callback = static function ( $data ) use ( $modified, &$callback ) { // The first post to reach this filter is the command's own. A save hook // can insert further posts before the outer call returns, and those must // keep their own dates, so the filter unhooks itself right away. self::remove_post_modified_filter( $callback ); return array_merge( $data, $modified ); }; // Attachments go through wp_insert_attachment_data instead. add_filter( 'wp_insert_post_data', $callback ); add_filter( 'wp_insert_attachment_data', $callback ); return $callback; } /** * Removes the callback registered by add_post_modified_filter(). * * The callback removes itself once it has run; this is the fallback for when * it never ran, for instance because wp_insert_post() bailed out early. * * @param callable $callback The callback to remove. * @return void */ private static function remove_post_modified_filter( $callback ) { remove_filter( 'wp_insert_post_data', $callback ); remove_filter( 'wp_insert_attachment_data', $callback ); } /** * Checks a date the way core checks post_date in wp_resolve_post_date(). * * @param string $date Date in MySQL format. * @return bool Whether the date is valid. */ private static function is_valid_post_date( $date ) { if ( ! preg_match( '/^(\d{4})-(\d{1,2})-(\d{1,2})/', $date, $matches ) ) { return false; } return wp_checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1], $date ); } /** * Updates one or more existing posts. * * ## OPTIONS * * ... * : One or more IDs of posts to update. * * [--post_author=] * : The ID of the user who added the post. Default is the current user ID. * * [--post_date=] * : The date of the post. Default is the current time. * * [--post_date_gmt=] * : The date of the post in the GMT timezone. Default is the value of $post_date. * * [--post_content=] * : The post content. Default empty. * * [--post_content_filtered=] * : The filtered post content. Default empty. * * [--post_title=] * : The post title. Default empty. * * [--post_excerpt=] * : The post excerpt. Default empty. * * [--post_status=] * : The post status. Default 'draft'. * * [--post_type=] * : The post type. Default 'post'. * * [--comment_status=] * : Whether the post can accept comments. Accepts 'open' or 'closed'. Default is the value of 'default_comment_status' option. * * [--ping_status=] * : Whether the post can accept pings. Accepts 'open' or 'closed'. Default is the value of 'default_ping_status' option. * * [--post_password=] * : The password to access the post. Default empty. * * [--post_name=] * : The post name. Default is the sanitized post title when creating a new post. * * [--to_ping=] * : Space or carriage return-separated list of URLs to ping. Default empty. * * [--pinged=] * : Space or carriage return-separated list of URLs that have been pinged. Default empty. * * [--post_modified=] * : The date when the post was last modified. Default is the current time. * * [--post_modified_gmt=] * : The date when the post was last modified in the GMT timezone. Default is the current time. * * [--post_parent=] * : Set this for the post it belongs to, if any. Default 0. * * [--menu_order=] * : The order the post should be displayed in. Default 0. * * [--post_mime_type=] * : The mime type of the post. Default empty. * * [--guid=] * : Global Unique ID for referencing the post. Default empty. * * [--post_category=] * : Array of category names, slugs, or IDs. Defaults to value of the 'default_category' option. * * [--tags_input=] * : Array of tag names, slugs, or IDs. Default empty. * * [--tax_input=] * : Array of taxonomy terms keyed by their taxonomy name. Default empty. * * Note: In WordPress core, this normally requires a user context to satisfy capability checks. WP-CLI bypasses this for convenience. See https://core.trac.wordpress.org/ticket/19373 * * [--meta_input=] * : Array in JSON format of post meta values keyed by their post meta key. Default empty. * * [--page_template=] * : Page template to use. * * [] * : Read post content from . If this value is present, the * `--post_content` argument will be ignored. * * Passing `-` as the filename will cause post content to * be read from STDIN. * * --= * : One or more fields to update. See wp_insert_post(). * * [--defer-term-counting] * : Recalculate term count in batch, for a performance boost. * * ## EXAMPLES * * $ wp post update 123 --post_name=something --post_status=draft * Success: Updated post 123. * * # Update a post with multiple meta values. * $ wp post update 123 --meta_input='{"key1":"value1","key2":"value2"}' * Success: Updated post 123. * * # Update multiple posts at once. * $ wp post update 123 456 --post_author=789 * Success: Updated post 123. * Success: Updated post 456. * * # Update all posts of a given post type at once. * $ wp post update $(wp post list --post_type=page --format=ids) --post_author=123 * Success: Updated post 123. * Success: Updated post 456. */ public function update( $args, $assoc_args ) { foreach ( $args as $key => $arg ) { if ( is_numeric( $arg ) ) { continue; } $assoc_args['post_content'] = $this->read_from_file_or_stdin( $arg ); unset( $args[ $key ] ); break; } if ( isset( $assoc_args['post_category'] ) ) { $assoc_args['post_category'] = $this->get_category_ids( $assoc_args['post_category'] ); } $array_arguments = [ 'meta_input', 'tax_input' ]; $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); $assoc_args = wp_slash( $assoc_args ); parent::_update( $args, $assoc_args, function ( $params ) { $modified_callback = self::add_post_modified_filter( $params ); if ( is_wp_error( $modified_callback ) ) { return $modified_callback; } $filter_callback = null; if ( 0 === get_current_user_id() && ! empty( $params['tax_input'] ) ) { $allowed_caps = []; /** * @var string $taxonomy */ foreach ( array_keys( $params['tax_input'] ) as $taxonomy ) { $tax_obj = get_taxonomy( $taxonomy ); if ( $tax_obj ) { $primitive_caps = map_meta_cap( $tax_obj->cap->assign_terms, 0 ); $allowed_caps = array_merge( $allowed_caps, $primitive_caps ); } } if ( ! empty( $allowed_caps ) ) { $filter_callback = function ( $allcaps, $caps ) use ( $allowed_caps ) { foreach ( $caps as $cap ) { if ( in_array( $cap, $allowed_caps, true ) ) { $allcaps[ $cap ] = true; } } return $allcaps; }; add_filter( 'user_has_cap', $filter_callback, 10, 2 ); } } $result = wp_update_post( $params, true ); if ( $modified_callback ) { self::remove_post_modified_filter( $modified_callback ); } if ( $filter_callback ) { remove_filter( 'user_has_cap', $filter_callback ); } return $result; } ); } /** * Launches system editor to edit post content. * * ## OPTIONS * * * : The ID of the post to edit. * * ## EXAMPLES * * # Launch system editor to edit post * $ wp post edit 123 */ public function edit( $args, $assoc_args ) { $post = $this->fetcher->get_check( $args[0] ); $result = $this->_edit( $post->post_content, "WP-CLI post {$post->ID}" ); if ( false === $result ) { WP_CLI::warning( 'No change made to post content.' ); } else { $this->update( $args, [ 'post_content' => $result ] ); } } // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Whitelisting to provide backward compatibility to classes possibly extending this class. protected function _edit( $content, $title ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. $content = apply_filters( 'the_editor_content', $content ); $output = Utils\launch_editor_for_input( $content, $title ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. return ( is_string( $output ) ) ? apply_filters( 'content_save_pre', $output ) : $output; } /** * Gets details about a post. * * ## OPTIONS * * * : The ID of the post to get. * * [--field=] * : Instead of returning the whole post, returns the value of a single field. * * [--fields=] * : Limit the output to specific fields. Defaults to all fields. * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - csv * - json * - yaml * --- * * ## EXAMPLES * * # Save the post content to a file * $ wp post get 123 --field=content > file.txt * * # Get the block version of a post (1 = has blocks, 0 = no blocks) * # Requires WordPress 5.0+. * $ wp post get 123 --field=block_version * 1 */ public function get( $args, $assoc_args ) { $post = $this->fetcher->get_check( $args[0] ); $post_arr = get_object_vars( $post ); unset( $post_arr['filter'] ); if ( ! isset( $post_arr['url'] ) ) { $post_arr['url'] = get_permalink( $post->ID ); } if ( function_exists( 'block_version' ) ) { $post_arr['block_version'] = block_version( $post->post_content ); } if ( empty( $assoc_args['fields'] ) ) { $assoc_args['fields'] = array_keys( $post_arr ); } $formatter = $this->get_formatter( $assoc_args ); $formatter->display_item( $post_arr ); } /** * Deletes an existing post. * * ## OPTIONS * * ... * : One or more IDs of posts to delete. * * [--force] * : Skip the trash bin. * * [--defer-term-counting] * : Recalculate term count in batch, for a performance boost. * * ## EXAMPLES * * # Delete post skipping trash * $ wp post delete 123 --force * Success: Deleted post 123. * * # Delete multiple posts * $ wp post delete 123 456 789 * Success: Trashed post 123. * Success: Trashed post 456. * Success: Trashed post 789. * * # Delete all pages * $ wp post delete $(wp post list --post_type='page' --format=ids) * Success: Trashed post 1164. * Success: Trashed post 1186. * * # Delete all posts in the trash * $ wp post delete $(wp post list --post_status=trash --format=ids) * Success: Deleted post 1268. * Success: Deleted post 1294. */ public function delete( $args, $assoc_args ) { $defaults = [ 'force' => false ]; $assoc_args = array_merge( $defaults, $assoc_args ); parent::_delete( $args, $assoc_args, [ $this, 'delete_callback' ] ); } /** * Callback used to delete a post. * * @param $post_id * @param $assoc_args * @return array */ protected function delete_callback( $post_id, $assoc_args ) { $status = get_post_status( $post_id ); $post_type = get_post_type( $post_id ); $force_delete = $assoc_args['force'] || 'trash' === $status || 'revision' === $post_type; if ( $force_delete || ! EMPTY_TRASH_DAYS ) { if ( ! wp_delete_post( $post_id, true ) ) { return [ 'error', "Failed deleting post {$post_id}." ]; } return [ 'success', "Deleted post {$post_id}." ]; } // Use wp_trash_post() directly because wp_delete_post() only auto-trashes // 'post' and 'page' types, permanently deleting all other post types even // when $force_delete is false. wp_trash_post() works for all post types. if ( wp_trash_post( $post_id ) ) { return [ 'success', "Trashed post {$post_id}." ]; } return [ 'error', "Failed trashing post {$post_id}." ]; } /** * Gets a list of posts. * * Display posts based on all arguments supported by [WP_Query()](https://developer.wordpress.org/reference/classes/wp_query/). * Only shows post types marked as post by default. * * ## OPTIONS * * [--=] * : One or more args to pass to WP_Query. The arguments below are the ones * that filter on what this command displays; anything else WP_Query accepts * still works and is documented with WP_Query itself. * * [--p=|ID] * : Filter by post ID. `--ID` is the name of the column this filters and is * accepted as an alias. * * [--title=|post_title] * : Filter by post title, matched in full. `--post_title` is the name of the * column this filters and is accepted as an alias. * * [--name=<slug>|post_name] * : Filter by post slug. `--post_name` is the name of the column this filters * and is accepted as an alias. * Note: this makes the query a single-post one, and WP_Query returns a draft * from one of those only to a user who can edit it. WP-CLI runs as no user * unless the global `--user` argument says otherwise, so pass that to filter * drafts by slug. * * [--author=<author>|post_author] * : Filter by the ID of the post's author. `--post_author` is the name of the * column this filters and is accepted as an alias. * * [--author_name=<author_name>] * : Filter by the 'user_nicename' of the post's author. * * [--cat=<cat>] * : Filter by category ID. Accepts a comma-separated list to match any of * them, and a negative ID excludes that category instead. * * [--category_name=<category_name>] * : Filter by category slug. * * [--tag=<tag>] * : Filter by tag slug. Accepts a comma-separated list to match any of them, * or a '+'-separated list to require all of them. * * [--post_type=<post_type>] * : Filter by post type. Defaults to 'post'. Accepts a comma-separated list, * or 'any' for every type registered without 'exclude_from_search'. * * [--post_status=<post_status>] * : Filter by post status. Defaults to 'any', which is every status * registered without 'exclude_from_search' - so trashed and auto-draft posts * are left out until asked for by name, e.g. `--post_status=trash`. * * [--post_parent=<post_parent>] * : Filter by the ID of the parent post. * * [--post_mime_type=<post_mime_type>] * : Filter by MIME type. Only attachments carry one. * * [--menu_order=<menu_order>] * : Filter by menu order. * * [--comment_status=<comment_status>] * : Filter by comment status. Accepts 'open' or 'closed'. * * [--ping_status=<ping_status>] * : Filter by ping status. Accepts 'open' or 'closed'. * * [--comment_count=<comment_count>] * : Filter by number of comments. * * [--s=<string>] * : Only list the posts matching this search term. * * [--year=<year>] * : Filter by four-digit year, e.g. 2024. * * [--monthnum=<monthnum>] * : Filter by month number, 1 to 12. * * [--day=<day>] * : Filter by day of the month, 1 to 31. * * [--hour=<hour>] * : Filter by hour, 0 to 23. * * [--minute=<minute>] * : Filter by minute, 0 to 59. * * [--second=<second>] * : Filter by second, 0 to 59. * * [--m=<yearmonth>] * : Filter by year and month together, e.g. 202401. * * [--w=<week>] * : Filter by week of the year, 0 to 53. * * [--meta_key=<meta_key>] * : Filter by posts having this meta key. Pair it with `--meta_value` to * filter on the value as well. * * [--meta_value=<meta_value>] * : Filter by this meta value. Needs `--meta_key` to say which key it * belongs to. * * [--orderby=<orderby>] * : Order the results by this field. Accepts what WP_Query accepts, e.g. * 'date', 'title', 'ID', 'menu_order', 'rand', or 'meta_value' alongside * `--meta_key`. * * [--order=<order>] * : Direction to order by. Accepts 'ASC' or 'DESC'. * * [--post__in=<ids>] * : Only list the posts with these IDs (comma-separated). * * [--post__not_in=<ids>] * : Exclude the posts with these IDs (comma-separated). * * [--post_name__in=<slugs>] * : Only list the posts with these slugs (comma-separated). Unlike `--name` * this is not a single-post query, so it reaches drafts as well. * * [--post_parent__in=<ids>] * : Only list the posts whose parent is one of these IDs (comma-separated). * * [--post_parent__not_in=<ids>] * : Exclude the posts whose parent is one of these IDs (comma-separated). * * [--author__in=<ids>] * : Only list the posts by these author IDs (comma-separated). * * [--author__not_in=<ids>] * : Exclude the posts by these author IDs (comma-separated). * * [--category__in=<ids>] * : Only list the posts in these category IDs (comma-separated). * * [--category__and=<ids>] * : Only list the posts in all of these category IDs (comma-separated). * * [--category__not_in=<ids>] * : Exclude the posts in these category IDs (comma-separated). * * [--tag_id=<tag_id>] * : Filter by tag ID. * * [--tag__in=<ids>] * : Only list the posts with these tag IDs (comma-separated). * * [--tag__and=<ids>] * : Only list the posts with all of these tag IDs (comma-separated). * * [--tag__not_in=<ids>] * : Exclude the posts with these tag IDs (comma-separated). * * [--tag_slug__in=<slugs>] * : Only list the posts with these tag slugs (comma-separated). * * [--tag_slug__and=<slugs>] * : Only list the posts with all of these tag slugs (comma-separated). * * [--page_id=<page_id>] * : Filter by page ID. Needs `--post_type=page` to match anything. * * [--pagename=<pagename>] * : Filter by page slug. Needs `--post_type=page` to match anything. * * [--attachment_id=<attachment_id>] * : Filter by attachment ID. Needs `--post_type=attachment` to match * anything. * * [--date_query=<json>] * : Filter by a date query, given as JSON. See WP_Date_Query. * * [--meta_query=<json>] * : Filter by a meta query, given as JSON. See WP_Meta_Query. * * [--tax_query=<json>] * : Filter by a taxonomy query, given as JSON. See WP_Tax_Query. * * [--meta_compare=<meta_compare>] * : Operator to test `--meta_value` with, e.g. '=', '!=', '>' or 'LIKE'. * * [--sentence=<sentence>] * : Match `--s` as one phrase rather than as separate words. Accepts 1 or 0. * * [--exact=<exact>] * : Match `--s` against the whole column rather than part of it. Accepts 1 * or 0. * * [--search_columns=<columns>] * : Comma-separated list of columns `--s` looks in. Accepts 'post_title', * 'post_excerpt' and 'post_content'. Needs WordPress 6.2 or later. * * [--perm=<perm>] * : Filter by what the current user may do with the post. Accepts * 'readable' or 'editable'; pair it with the global `--user` argument, * since WP-CLI is no user by default. * * [--posts_per_page=<number>] * : How many posts to return. Defaults to -1, meaning every post. * * [--paged=<paged>] * : Which page of results to return, counted in `--posts_per_page` steps. * * [--offset=<offset>] * : How many posts to skip. Needs `--posts_per_page` set to something other * than -1, which otherwise takes precedence. * * [--nopaging=<nopaging>] * : Return every post, ignoring `--posts_per_page`. Accepts 1 or 0. * * [--field=<field>] * : Prints the value of a single field for each post. * * [--fields=<fields>] * : Limit the output to specific object fields. * * [--format=<format>] * : Render output in a particular format. * --- * default: table * options: * - table * - csv * - ids * - json * - count * - yaml * --- * * ## AVAILABLE FIELDS * * These fields will be displayed by default for each post: * * * ID * * post_title * * post_name * * post_date * * post_status * * These fields are optionally available: * * * post_author * * post_date_gmt * * post_content * * post_excerpt * * comment_status * * ping_status * * post_password * * to_ping * * pinged * * post_modified * * post_modified_gmt * * post_content_filtered * * post_parent * * guid * * menu_order * * post_type * * post_mime_type * * comment_count * * url * * ## EXAMPLES * * # List post * $ wp post list --field=ID * 568 * 829 * 1329 * 1695 * * # List posts in JSON * $ wp post list --post_type=post --posts_per_page=5 --format=json * [{"ID":1,"post_title":"Hello world!","post_name":"hello-world","post_date":"2015-06-20 09:00:10","post_status":"publish"},{"ID":1178,"post_title":"Markup: HTML Tags and Formatting","post_name":"markup-html-tags-and-formatting","post_date":"2013-01-11 20:22:19","post_status":"draft"}] * * # List all pages * $ wp post list --post_type=page --fields=post_title,post_status * +-------------+-------------+ * | post_title | post_status | * +-------------+-------------+ * | Sample Page | publish | * +-------------+-------------+ * * # List ids of all pages and posts * $ wp post list --post_type=page,post --format=ids * 15 25 34 37 198 * * # List given posts * $ wp post list --post__in=1,3 * +----+--------------+-------------+---------------------+-------------+ * | ID | post_title | post_name | post_date | post_status | * +----+--------------+-------------+---------------------+-------------+ * | 3 | Lorem Ipsum | lorem-ipsum | 2016-06-01 14:34:36 | publish | * | 1 | Hello world! | hello-world | 2016-06-01 14:31:12 | publish | * +----+--------------+-------------+---------------------+-------------+ * * # List given post by a specific author * $ wp post list --author=2 * +----+-------------------+-------------------+---------------------+-------------+ * | ID | post_title | post_name | post_date | post_status | * +----+-------------------+-------------------+---------------------+-------------+ * | 14 | New documentation | new-documentation | 2021-06-18 21:05:11 | publish | * +----+-------------------+-------------------+---------------------+-------------+ * * @subcommand list */ public function list_( $args, $assoc_args ) { $formatter = $this->get_formatter( $assoc_args ); $defaults = [ 'posts_per_page' => -1, 'post_status' => 'any', ]; $array_arguments = [ 'date_query', 'tax_query', 'meta_query' ]; $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); $query_args = array_merge( $defaults, $assoc_args ); $query_args = self::process_csv_arguments_to_arrays( $query_args ); if ( isset( $query_args['post_type'] ) && 'any' !== $query_args['post_type'] ) { $query_args['post_type'] = explode( ',', $query_args['post_type'] ); } if ( 'ids' === $formatter->format ) { $query_args['fields'] = 'ids'; $query = new WP_Query( $query_args ); // @phpstan-ignore argument.type echo implode( ' ', $query->posts ); } elseif ( 'count' === $formatter->format ) { $query_args['fields'] = 'ids'; $query = new WP_Query( $query_args ); $formatter->display_items( $query->posts ?? [] ); } else { $query = new WP_Query( $query_args ); $posts = array_map( function ( $post ) { /** * @var \WP_Post $post */ // @phpstan-ignore property.notFound $post->url = get_permalink( $post->ID ); return $post; }, $query->posts ?? [] ); $formatter->display_items( $posts ); } } /** * Generates some posts. * * Creates a specified number of new posts with dummy data. * * ## OPTIONS * * [--count=<number>] * : How many posts to generate? * --- * default: 100 * --- * * [--post_type=<type>] * : The type of the generated posts. * --- * default: post * --- * * [--post_status=<status>] * : The status of the generated posts. * --- * default: publish * --- * * [--post_title=<post_title>] * : The post title. * --- * default: * --- * * [--post_author=<login>] * : The author of the generated posts. * --- * default: * --- * * [--post_date=<yyyy-mm-dd-hh-ii-ss>] * : The date of the post. Default is the current time. * * [--post_date_gmt=<yyyy-mm-dd-hh-ii-ss>] * : The date of the post in the GMT timezone. Default is the value of --post_date. * * [--post_content] * : If set, the command reads the post_content from STDIN. * * [--max_depth=<number>] * : For hierarchical post types, generate child posts down to a certain depth. * --- * default: 1 * --- * * [--format=<format>] * : Render output in a particular format. * --- * default: progress * options: * - progress * - ids * --- * * ## EXAMPLES * * # Generate posts. * $ wp post generate --count=10 --post_type=page --post_date=1999-01-04 * Generating posts 100% [================================================] 0:01 / 0:04 * * # Generate posts with fetched content. * $ curl -N https://loripsum.net/api/5 | wp post generate --post_content --count=10 * % Total % Received % Xferd Average Speed Time Time Time Current * Dload Upload Total Spent Left Speed * 100 2509 100 2509 0 0 616 0 0:00:04 0:00:04 --:--:-- 616 * Generating posts 100% [================================================] 0:01 / 0:04 * * # Add meta to every generated posts. * $ wp post generate --format=ids | xargs -d ' ' -I % wp post meta add % foo bar * Success: Added custom field. * Success: Added custom field. * Success: Added custom field. * * @param array<string> $args Positional arguments. Unused. * @param array{count: string, post_type: string, post_status: string, post_title: string, post_author: string, post_date?: string, post_date_gmt?: string, post_content?: string, max_depth: string, format: string} $assoc_args Associative arguments. */ public function generate( $args, $assoc_args ) { global $wpdb; $defaults = [ 'count' => 100, 'max_depth' => 1, 'post_type' => 'post', 'post_status' => 'publish', 'post_author' => false, 'post_date' => '', 'post_date_gmt' => '', 'post_content' => '', 'post_title' => '', ]; $post_data = array_merge( $defaults, $assoc_args ); $post_data['post_date'] = $this->maybe_convert_hyphenated_date_format( $post_data['post_date'] ); $post_data['post_date_gmt'] = $this->maybe_convert_hyphenated_date_format( $post_data['post_date_gmt'] ); // Add time if the string is a valid date without time. $date = DateTime::createFromFormat( 'Y-m-d', $post_data['post_date'] ); if ( $date && $date->format( 'Y-m-d' ) === $post_data['post_date'] ) { $post_data['post_date'] .= ' 00:00:00'; } $date_gmt = DateTime::createFromFormat( 'Y-m-d', $post_data['post_date_gmt'] ); if ( $date_gmt && $date_gmt->format( 'Y-m-d' ) === $post_data['post_date_gmt'] ) { $post_data['post_date_gmt'] .= ' 00:00:00'; } // In older WordPress versions, wp_insert_post post dates default to the current time when a value is absent. We need to send a value for post_date_gmt if post_date is set and vice versa. if ( ! empty( $post_data['post_date'] ) && empty( $post_data['post_date_gmt'] ) ) { $post_data['post_date_gmt'] = get_gmt_from_date( $post_data['post_date'] ); } if ( ! empty( $post_data['post_date_gmt'] ) && empty( $post_data['post_date'] ) ) { $post_data['post_date'] = get_date_from_gmt( $post_data['post_date_gmt'] ); } if ( ! post_type_exists( $post_data['post_type'] ) ) { WP_CLI::error( "'{$post_data['post_type']}' is not a registered post type." ); } if ( $post_data['post_author'] ) { $user_fetcher = new UserFetcher(); $post_data['post_author'] = $user_fetcher->get_check( $post_data['post_author'] )->ID; } if ( Utils\get_flag_value( $assoc_args, 'post_content' ) ) { if ( ! Utils\has_stdin() ) { WP_CLI::error( 'The parameter `post_content` reads from STDIN.' ); } $post_data['post_content'] = (string) file_get_contents( 'php://stdin' ); } // Get the total number of posts. $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = %s", $post_data['post_type'] ) ); /** * @var \WP_Post_Type $post_type */ $post_type = get_post_type_object( $post_data['post_type'] ); $label = ! empty( $post_data['post_title'] ) ? $post_data['post_title'] : $post_type->labels->singular_name; $limit = $post_data['count'] + $total; $format = Utils\get_flag_value( $assoc_args, 'format', 'progress' ); $notify = false; if ( 'progress' === $format ) { $notify = Utils\make_progress_bar( 'Generating posts', (int) $post_data['count'] ); } $previous_post_id = 0; $current_depth = 1; $current_parent = 0; for ( $index = $total; $index < $limit; $index++ ) { if ( $post_type->hierarchical ) { if ( $this->maybe_make_child() && $current_depth < $post_data['max_depth'] ) { $current_parent = $previous_post_id; ++$current_depth; } elseif ( $this->maybe_reset_depth() ) { $current_depth = 1; $current_parent = 0; } } $args = [ 'post_type' => $post_data['post_type'], 'post_title' => ( ! empty( $post_data['post_title'] ) && $index === $total ) ? $label : "{$label} {$index}", 'post_status' => $post_data['post_status'], 'post_author' => (int) $post_data['post_author'], 'post_parent' => $current_parent, 'post_name' => ! empty( $post_data['post_title'] ) ? sanitize_title( $post_data['post_title'] . ( $index === $total ? '' : "-{$index}" ) ) : "post-{$index}", 'post_date' => $post_data['post_date'], 'post_date_gmt' => $post_data['post_date_gmt'], 'post_content' => $post_data['post_content'], ]; $post_id = wp_insert_post( $args, true ); if ( is_wp_error( $post_id ) ) { WP_CLI::warning( $post_id ); } else { $previous_post_id = $post_id; if ( 'ids' === $format ) { echo $post_id; if ( $index < $limit - 1 ) { echo ' '; } } } if ( 'progress' === $format ) { // @phpstan-ignore method.nonObject $notify->tick(); } } if ( 'progress' === $format ) { // @phpstan-ignore method.nonObject $notify->finish(); } } /** * Gets the post ID for a given URL. * * ## OPTIONS * * <url> * : The URL of the post to get. * * ## EXAMPLES * * # Get post ID by URL * $ wp post url-to-id https://example.com/?p=1 * 1 * * @subcommand url-to-id */ public function url_to_id( $args, $assoc_args ) { $post_id = url_to_postid( $args[0] ); $post = get_post( $post_id ); if ( null === $post ) { WP_CLI::error( "Could not get post with url $args[0]." ); } WP_CLI::print_value( $post_id, $assoc_args ); } private function maybe_make_child() { // 50% chance of making child post. return ( wp_rand( 1, 2 ) === 1 ); } private function maybe_reset_depth() { // 10% chance of resetting to root depth, return ( wp_rand( 1, 10 ) === 7 ); } /** * Read post content from file or STDIN * * @param string $arg Supplied argument * @return string */ private function read_from_file_or_stdin( $arg ) { if ( '-' !== $arg ) { $readfile = $arg; if ( ! file_exists( $readfile ) || ! is_file( $readfile ) ) { WP_CLI::error( "Unable to read content from '{$readfile}'." ); } } else { $readfile = 'php://stdin'; } return (string) file_get_contents( $readfile ); } /** * Resolves post_category arg into an array of category ids. * * @param string $arg Supplied argument. * @return array */ private function get_category_ids( $arg ) { $categories = explode( ',', $arg ); $category_ids = []; foreach ( $categories as $post_category ) { if ( trim( $post_category ) ) { if ( is_numeric( $post_category ) && (int) $post_category ) { $category_id = category_exists( (int) $post_category ); } else { $category_id = category_exists( $post_category ); } if ( ! $category_id ) { WP_CLI::error( "No such post category '{$post_category}'." ); } $category_ids[] = $category_id; } } // If no category ids found, return exploded array for compat with previous WP-CLI versions. return $category_ids ?: $categories; } /** * Get post metadata. * * @param $post_id ID of the post. * * @return array */ private function get_metadata( $post_id ) { /** * @var array<string, array<string>> $metadata */ $metadata = get_metadata( 'post', $post_id ); $items = []; foreach ( $metadata as $key => $values ) { foreach ( $values as $item_value ) { $item_value = maybe_unserialize( $item_value ); $items[ $key ] = $item_value; } } return $items; } /** * Get Categories of a post. * * @param $post_id ID of the post. * * @return array */ private function get_category( $post_id ) { $category_data = get_the_category( $post_id ); $category_arr = []; foreach ( $category_data as $cat ) { array_push( $category_arr, $cat->term_id ); } return $category_arr; } /** * Get Tags of a post. * * @param $post_id ID of the post. * * @return array */ private function get_tags( $post_id ) { $tag_data = get_the_tags( $post_id ); $tag_arr = []; if ( $tag_data && ! is_wp_error( $tag_data ) ) { foreach ( $tag_data as $tag ) { array_push( $tag_arr, $tag->slug ); } } return $tag_arr; } /** * Verifies whether a post exists. * * Displays a success message if the post does exist. * * ## OPTIONS * * <id> * : The ID of the post to check. * * ## EXAMPLES * * # The post exists. * $ wp post exists 1337 * Success: Post with ID 1337 exists. * $ echo $? * 0 * * # The post does not exist. * $ wp post exists 10000 * $ echo $? * 1 */ public function exists( $args ) { if ( $this->fetcher->get( $args[0] ) ) { WP_CLI::success( "Post with ID {$args[0]} exists." ); } else { WP_CLI::halt( 1 ); } } /** * Checks if a post contains any blocks. * * Exits with return code 0 if the post contains blocks, * or return code 1 if it does not. * * ## OPTIONS * * <id> * : The ID of the post to check. * * ## EXAMPLES * * # Check if post contains blocks. * $ wp post has-blocks 123 * Success: Post 123 contains blocks. * * # Check a classic (non-block) post. * $ wp post has-blocks 456 * Error: Post 456 does not contain blocks. * * # Use in a shell conditional. * $ if wp post has-blocks 123 2>/dev/null; then * > echo "Post uses blocks" * > fi * * @subcommand has-blocks */ public function has_blocks( $args, $assoc_args ) { $post = $this->fetcher->get_check( $args[0] ); if ( Block_Processor_Helper::has_blocks( $post->post_content ) ) { WP_CLI::success( "Post {$post->ID} contains blocks." ); } else { WP_CLI::error( "Post {$post->ID} does not contain blocks." ); } } /** * Checks if a post contains a specific block type. * * Exits with return code 0 if the post contains the specified block, * or return code 1 if it does not. * * ## OPTIONS * * <id> * : The ID of the post to check. * * <block-name> * : The block type name to check for (e.g., 'core/paragraph'). * * ## EXAMPLES * * # Check if post contains a paragraph block. * $ wp post has-block 123 core/paragraph * Success: Post 123 contains block 'core/paragraph'. * * # Check for a heading block. * $ wp post has-block 123 core/heading * Success: Post 123 contains block 'core/heading'. * * # Check for a block that doesn't exist. * $ wp post has-block 123 core/gallery * Error: Post 123 does not contain block 'core/gallery'. * * # Check for a custom block from a plugin. * $ wp post has-block 123 my-plugin/custom-block * * @subcommand has-block */ public function has_block( $args, $assoc_args ) { if ( ! function_exists( 'has_block' ) ) { WP_CLI::error( 'Requires WordPress 5.0 or greater.' ); } $post = $this->fetcher->get_check( $args[0] ); $block_name = $args[1]; if ( has_block( $block_name, $post ) ) { WP_CLI::success( "Post {$post->ID} contains block '{$block_name}'." ); } else { WP_CLI::error( "Post {$post->ID} does not contain block '{$block_name}'." ); } } /** * Convert a date-time string with a hyphen separator to a space separator. * * @param string $date_string The date-time string to convert. * @return string The converted date-time string. * * Example: * maybe_convert_hyphenated_date_format( "2018-07-05-17:17:17" ); * Returns: "2018-07-05 17:17:17" */ private function maybe_convert_hyphenated_date_format( $date_string ) { // Check if the date string matches the format with the hyphen between date and time. if ( preg_match( '/^(\d{4}-\d{2}-\d{2})-(\d{2}:\d{2}:\d{2})$/', $date_string, $matches ) ) { return $matches[1] . ' ' . $matches[2]; } return $date_string; } }