array( 'name' => __( 'Guides', 'cedar-hintwise' ), 'singular_name' => __( 'Guide', 'cedar-hintwise' ), 'add_new' => __( 'Add guide', 'cedar-hintwise' ), 'add_new_item' => __( 'Add new guide', 'cedar-hintwise' ), 'edit_item' => __( 'Edit guide', 'cedar-hintwise' ), 'new_item' => __( 'New guide', 'cedar-hintwise' ), 'view_item' => __( 'View guide', 'cedar-hintwise' ), 'search_items' => __( 'Search guides', 'cedar-hintwise' ), 'not_found' => __( 'No guides yet.', 'cedar-hintwise' ), 'not_found_in_trash' => __( 'No guides in the trash.', 'cedar-hintwise' ), 'parent_item_colon' => __( 'Section:', 'cedar-hintwise' ), 'all_items' => __( 'All guides', 'cedar-hintwise' ), 'menu_name' => __( 'Guides', 'cedar-hintwise' ), ), 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'show_ui' => true, 'show_in_menu' => false, 'show_in_nav_menus' => false, 'show_in_admin_bar' => false, 'show_in_rest' => true, 'hierarchical' => true, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, 'capability_type' => array( 'hintwise_doc', 'hintwise_docs' ), 'map_meta_cap' => true, 'supports' => array( 'title', 'editor', 'page-attributes', 'revisions', 'custom-fields' ), ) ); foreach ( array( self::META_ROLES, self::META_SCREENS ) as $key ) { register_post_meta( self::TYPE, $key, array( 'type' => 'array', 'single' => true, 'show_in_rest' => array( 'schema' => array( 'items' => array( 'type' => 'string' ) ) ), 'sanitize_callback' => array( __CLASS__, 'sanitize_list' ), 'auth_callback' => array( 'Hintwise_Settings', 'can_manage' ), 'default' => array(), ) ); } } /** * Give guide editing rights to whoever holds the manage capability. * * @param array $allcaps All caps of the user. * @param array $caps Required primitive caps. * @param array $args Request args. * @param WP_User $user User. * @return array */ public static function grant_caps( $allcaps, $caps, $args, $user ) { $wanted = array_filter( $caps, function ( $c ) { return false !== strpos( $c, 'hintwise_doc' ); } ); if ( ! $wanted ) { return $allcaps; } $manage = Hintwise_Settings::manage_cap(); if ( ! empty( $allcaps[ $manage ] ) ) { foreach ( $wanted as $c ) { $allcaps[ $c ] = true; } } return $allcaps; } /** * Guides are internal: only people who can manage them may use the REST routes * (the block editor uses them). Everybody else reads guides in wp-admin. * * @param array $endpoints Routes. * @return array */ public static function lock_rest( $endpoints ) { foreach ( $endpoints as $route => &$handlers ) { if ( 0 !== strpos( $route, '/wp/v2/' . self::TYPE ) ) { continue; } foreach ( $handlers as $i => $handler ) { if ( ! is_array( $handler ) || ! isset( $handler['callback'] ) ) { continue; } $orig = $handler['permission_callback'] ?? '__return_true'; $handlers[ $i ]['permission_callback'] = function ( $request ) use ( $orig ) { if ( ! Hintwise_Settings::can_manage() ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to do that.', 'cedar-hintwise' ), array( 'status' => rest_authorization_required_code() ) ); } return is_callable( $orig ) ? call_user_func( $orig, $request ) : true; }; } } return $endpoints; } /** * Never list guides in core sitemaps. * * @param array $types Post types. * @return array */ public static function no_sitemap( $types ) { unset( $types[ self::TYPE ] ); return $types; } /** * Sanitize a list of keys. * * @param mixed $value Raw. * @return string[] */ public static function sanitize_list( $value ) { if ( ! is_array( $value ) ) { $value = '' === (string) $value ? array() : explode( ',', (string) $value ); } $out = array(); foreach ( $value as $v ) { $v = trim( sanitize_text_field( (string) $v ) ); $v = preg_replace( '/[^A-Za-z0-9_:\-.]/', '', $v ); if ( '' !== $v ) { $out[] = $v; } } return array_values( array_unique( $out ) ); } /** * Roles a guide is limited to (empty = everyone who can open wp-admin). * * @param int $id Guide ID. * @return string[] */ public static function roles( $id ) { $r = get_post_meta( $id, self::META_ROLES, true ); return is_array( $r ) ? $r : array(); } /** * Screens a guide is pinned to. * * @param int $id Guide ID. * @return string[] */ public static function screens( $id ) { $s = get_post_meta( $id, self::META_SCREENS, true ); return is_array( $s ) ? $s : array(); } /** * Whether a user may read a guide. * * @param WP_Post|int $doc Guide. * @param WP_User|null $user User (current user by default). * @param bool $as_parent Checking a section above the guide: only its own role limits count. * @return bool */ public static function can_read( $doc, $user = null, $as_parent = false ) { $doc = get_post( $doc ); $user = $user ? $user : wp_get_current_user(); if ( ! $doc || self::TYPE !== $doc->post_type || ! $user || ! $user->exists() ) { return false; } if ( user_can( $user, Hintwise_Settings::manage_cap() ) ) { return true; } if ( 'publish' !== $doc->post_status ) { return false; } $roles = self::roles( $doc->ID ); if ( $roles ) { if ( ! array_intersect( $roles, (array) $user->roles ) ) { return false; } } elseif ( ! $as_parent && ! user_can( $user, self::default_read_cap() ) ) { // Guides open to "everyone" are for the site's staff, not subscribers or customers. return false; } // A guide inside a hidden section is hidden too. if ( $doc->post_parent && ! self::can_read( $doc->post_parent, $user, true ) ) { return false; } /** * Filters whether a user may read a guide. * * @param bool $can Whether they can. * @param WP_Post $doc Guide. * @param WP_User $user User. */ return (bool) apply_filters( 'hintwise_can_read', true, $doc, $user ); } /** * Guides the current user may read, in handbook order. * * @param array $args Extra get_posts() args. * @return WP_Post[] */ public static function readable( $args = array() ) { $status = Hintwise_Settings::can_manage() ? array( 'publish', 'draft', 'pending', 'private', 'future' ) : array( 'publish' ); $posts = get_posts( array_merge( array( 'post_type' => self::TYPE, 'post_status' => $status, 'numberposts' => 500, 'orderby' => array( 'menu_order' => 'ASC', 'title' => 'ASC', ), 'suppress_filters' => false, ), $args ) ); return array_values( array_filter( $posts, array( __CLASS__, 'can_read' ) ) ); } /** * Capability needed to read guides that are not limited to certain roles. * * @return string */ public static function default_read_cap() { /** * Filters the capability needed to read guides that are open to everyone. * * @param string $cap Default 'edit_posts' (contributors and up, shop managers). */ return (string) apply_filters( 'hintwise_read_capability', 'edit_posts' ); } /** * Whether the current user has anything to read (decides whether the menu shows). * * @return bool */ public static function has_any() { if ( Hintwise_Settings::can_manage() || current_user_can( self::default_read_cap() ) ) { return true; } return (bool) self::readable( array( 'post_status' => 'publish', 'numberposts' => 50, ) ); } /** * Readable guides as a tree, flattened depth-first: [ [post, depth], ... ]. * * @return array */ public static function tree() { $docs = self::readable(); $by_id = array(); $children = array(); foreach ( $docs as $d ) { $by_id[ $d->ID ] = $d; } foreach ( $docs as $d ) { $parent = isset( $by_id[ $d->post_parent ] ) ? $d->post_parent : 0; $children[ $parent ][] = $d; } $out = array(); $walk = function ( $parent, $depth ) use ( &$walk, &$out, $children ) { foreach ( $children[ $parent ] ?? array() as $d ) { $out[] = array( $d, $depth ); $walk( $d->ID, $depth + 1 ); } }; $walk( 0, 0 ); return $out; } /** * Readable guides pinned to any of the given screen keys. * * @param string[] $keys Screen keys. * @return WP_Post[] */ public static function for_screens( $keys ) { if ( ! $keys ) { return array(); } $out = array(); foreach ( self::readable() as $d ) { if ( 'publish' === $d->post_status && array_intersect( $keys, self::screens( $d->ID ) ) ) { $out[] = $d; } } return $out; } /** * Render a guide's content to HTML for wp-admin, without running front-end * the_content filters from other plugins (share buttons, ads, ...). * * @param WP_Post|int $doc Guide. * @return string */ public static function render( $doc ) { global $wp_embed; $doc = get_post( $doc ); if ( ! $doc ) { return ''; } $html = self::expand( $doc->post_content ); if ( $wp_embed instanceof WP_Embed ) { $html = $wp_embed->run_shortcode( $html ); $html = $wp_embed->autoembed( $html ); } $html = do_blocks( $html ); $html = wptexturize( $html ); if ( ! has_blocks( $doc->post_content ) ) { $html = wpautop( $html ); } $html = shortcode_unautop( $html ); $html = do_shortcode( $html ); $html = wp_filter_content_tags( $html ); /** * Filters a guide's rendered HTML. * * @param string $html HTML. * @param WP_Post $doc Guide. */ return (string) apply_filters( 'hintwise_render', $html, $doc ); } /** * Expand {{placeholders}} that guides may use. * * {{site}} site name · {{home}} home URL · {{admin:path}} a wp-admin URL · * {{contact}} / {{email}} / {{phone}} the support contact from the settings. * * @param string $text Text. * @return string */ public static function expand( $text ) { $s = Hintwise_Settings::get(); $name = $s['contact_name'] ? $s['contact_name'] : __( 'your web team', 'cedar-hintwise' ); $map = array( '{{site}}' => esc_html( wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), '{{home}}' => esc_url( home_url( '/' ) ), '{{contact}}' => esc_html( $name ), '{{email}}' => esc_html( $s['contact_email'] ), '{{phone}}' => esc_html( $s['contact_phone'] ), ); $text = strtr( $text, $map ); return preg_replace_callback( '/\{\{admin:([A-Za-z0-9_.\-?=&%\/]*)\}\}/', function ( $m ) { return esc_url( admin_url( $m[1] ) ); }, $text ); } /** * Plain-text version of a guide (for search). * * @param WP_Post $doc Guide. * @return string */ public static function plain( $doc ) { $text = wp_strip_all_tags( self::expand( strip_shortcodes( $doc->post_content ) ) ); return trim( preg_replace( '/\s+/', ' ', html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ) ) ); } /** * Link to a guide in the handbook. * * @param int $id Guide ID. * @return string */ public static function url( $id ) { return add_query_arg( array( 'page' => 'hintwise', 'doc' => (int) $id, ), admin_url( 'admin.php' ) ); } }