get_var( "SELECT COUNT(*) FROM {$wpdb->term_taxonomy} WHERE taxonomy IN ('flex_tab_page','flex_tab_post')" ); if ( $old ) { $ids = $wpdb->get_col( "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE taxonomy IN ('flex_tab_page','flex_tab_post')" ); $wpdb->query( "UPDATE {$wpdb->term_taxonomy} SET taxonomy = CONCAT('tabwise_', SUBSTRING(taxonomy, 10)) WHERE taxonomy IN ('flex_tab_page','flex_tab_post')" ); clean_term_cache( array_map( 'intval', $ids ) ); foreach ( $ids as $term_id ) { foreach ( array( 'color', 'icon' ) as $key ) { $val = get_term_meta( (int) $term_id, 'ft_' . $key, true ); if ( '' !== $val ) { update_term_meta( (int) $term_id, 'tabwise_' . $key, $val ); delete_term_meta( (int) $term_id, 'ft_' . $key ); } } } } // phpcs:enable foreach ( array( 'settings', 'default', 'order', 'colors' ) as $opt ) { $val = get_option( 'flex_tabs_' . $opt, null ); if ( null !== $val ) { if ( false === get_option( 'tabwise_' . $opt ) ) { update_option( 'tabwise_' . $opt, $val, false ); } delete_option( 'flex_tabs_' . $opt ); } } } /** * Quick Edit "Tab" field. Core has already verified the inline-edit * nonce and edit permission before save_post fires for inline-save. */ public static function quick_edit_save( $post_id, $post ) { if ( ! wp_doing_ajax() || ! isset( $_POST['action'], $_POST['tw_tab'] ) || 'inline-save' !== $_POST['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification return; } if ( ! in_array( $post->post_type, self::post_types(), true ) || ! self::can_manage() ) { return; } check_ajax_referer( 'inlineeditnonce', '_inline_edit' ); $raw = sanitize_key( wp_unslash( $_POST['tw_tab'] ) ); self::move_posts( $post->post_type, array( $post_id ), 'unassigned' === $raw ? 0 : absint( $raw ) ); } /* ------------------------------------------------------------------ * * Settings / permissions * ------------------------------------------------------------------ */ public static function settings() { $s = get_option( self::OPT_SETTINGS, array() ); return wp_parse_args( is_array( $s ) ? $s : array(), array( 'posts' => 0, 'cleanup' => 0, 'roles' => array( 'administrator' ), 'visible' => 5, 'custom_icons' => 0, ) ); } /** Administrators always can; other roles only if allowed in Settings. */ public static function can_manage() { if ( current_user_can( 'manage_options' ) ) { return true; } $user = wp_get_current_user(); if ( ! $user || ! $user->exists() ) { return false; } return (bool) array_intersect( (array) $user->roles, (array) self::settings()['roles'] ); } public static function post_types() { $types = array( 'page' ); if ( self::settings()['posts'] ) { $types[] = 'post'; } return $types; } public static function tax( $pt ) { return 'tabwise_' . $pt; } public static function register_taxonomies() { foreach ( self::post_types() as $pt ) { register_taxonomy( self::tax( $pt ), $pt, 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' => false, 'query_var' => false, 'rewrite' => false, 'labels' => array( 'name' => 'Tabwise' ), ) ); } } /* ------------------------------------------------------------------ * * List screen (edit.php) * ------------------------------------------------------------------ */ public static function load_list_screen() { $pt = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : 'post'; // phpcs:ignore WordPress.Security.NonceVerification if ( ! in_array( $pt, self::post_types(), true ) || ! self::can_manage() ) { return; } add_action( 'pre_get_posts', array( __CLASS__, 'filter_query' ) ); add_filter( 'views_edit-' . $pt, array( __CLASS__, 'keep_tab_in_views' ) ); add_action( 'restrict_manage_posts', array( __CLASS__, 'hidden_tab_input' ) ); add_filter( 'bulk_actions-edit-' . $pt, array( __CLASS__, 'bulk_actions' ) ); add_filter( 'handle_bulk_actions-edit-' . $pt, array( __CLASS__, 'handle_bulk' ), 10, 3 ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); } /** Active tab: term id (int), 'all' or 'unassigned'. */ public static function current_tab( $pt ) { $raw = isset( $_GET[ self::QV ] ) ? sanitize_key( wp_unslash( $_GET[ self::QV ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification if ( '' === $raw ) { $raw = self::get_default( $pt ); } if ( 'all' === $raw || 'unassigned' === $raw ) { return $raw; } $id = absint( $raw ); if ( $id && term_exists( $id, self::tax( $pt ) ) ) { return $id; } foreach ( self::get_order( $pt ) as $key ) { if ( ctype_digit( (string) $key ) ) { return (int) $key; } } return 'all'; } public static function filter_query( $q ) { if ( ! $q->is_main_query() ) { return; } $pt = $q->get( 'post_type' ) ? $q->get( 'post_type' ) : 'post'; if ( ! in_array( $pt, self::post_types(), true ) ) { return; } $tab = self::current_tab( $pt ); if ( 'all' === $tab ) { return; } $tq = array_filter( (array) $q->get( 'tax_query' ) ); $tq[] = 'unassigned' === $tab ? array( 'taxonomy' => self::tax( $pt ), 'operator' => 'NOT EXISTS', ) : array( 'taxonomy' => self::tax( $pt ), 'field' => 'term_id', 'terms' => array( $tab ), ); $q->set( 'tax_query', $tq ); } public static function keep_tab_in_views( $views ) { $tab = self::current_tab( get_current_screen()->post_type ); foreach ( $views as $k => $html ) { $views[ $k ] = preg_replace_callback( '/href=([\'"])([^\'"]+)\1/', function ( $m ) use ( $tab ) { return 'href="' . esc_url( add_query_arg( self::QV, $tab, html_entity_decode( $m[2] ) ) ) . '"'; }, $html ); } return $views; } public static function hidden_tab_input( $post_type ) { if ( in_array( $post_type, self::post_types(), true ) ) { printf( '', esc_attr( self::QV ), esc_attr( self::current_tab( $post_type ) ) ); } } /* ------------------------------------------------------------------ * * Data * ------------------------------------------------------------------ */ public static function get_default( $pt ) { $d = get_option( self::OPT_DEFAULT, array() ); return ( is_array( $d ) && isset( $d[ $pt ] ) ) ? (string) $d[ $pt ] : ''; } /** Ordered list of tab keys (term ids as strings, plus 'unassigned' and 'all'). */ public static function get_order( $pt ) { $ids = get_terms( array( 'taxonomy' => self::tax( $pt ), 'hide_empty' => false, 'fields' => 'ids', 'orderby' => 'term_id', 'order' => 'ASC', ) ); $ids = is_wp_error( $ids ) ? array() : array_map( 'strval', $ids ); $saved = get_option( self::OPT_ORDER, array() ); $saved = ( is_array( $saved ) && isset( $saved[ $pt ] ) ) ? (array) $saved[ $pt ] : array(); $valid = array_merge( $ids, array( 'unassigned', 'all' ) ); $order = array_values( array_intersect( array_map( 'strval', $saved ), $valid ) ); foreach ( $ids as $id ) { // New tabs go after other custom tabs. if ( ! in_array( $id, $order, true ) ) { $pos = array_search( 'unassigned', $order, true ); false === $pos ? $order[] = $id : array_splice( $order, $pos, 0, array( $id ) ); } } foreach ( array( 'unassigned', 'all' ) as $sys ) { if ( ! in_array( $sys, $order, true ) ) { $order[] = $sys; } } return $order; } private static function save_order( $pt, $order ) { $all = get_option( self::OPT_ORDER, array() ); $all = is_array( $all ) ? $all : array(); $all[ $pt ] = array_values( array_map( 'strval', $order ) ); update_option( self::OPT_ORDER, $all, false ); } /** Counts per tab, excluding trash and auto-drafts. */ public static function counts( $pt ) { global $wpdb; $out = array(); $cache_key = 'counts:' . $pt . ':' . wp_cache_get_last_changed( 'posts' ) . ':' . wp_cache_get_last_changed( 'terms' ); $rows = wp_cache_get( $cache_key, 'tabwise' ); if ( false === $rows ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- One grouped count; cached above. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT tt.term_id, COUNT(*) AS c FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id INNER JOIN {$wpdb->posts} 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", self::tax( $pt ), $pt ) ); wp_cache_set( $cache_key, $rows, 'tabwise' ); } foreach ( $rows as $r ) { $out[ (string) $r->term_id ] = (int) $r->c; } $c = (array) wp_count_posts( $pt ); unset( $c['trash'], $c['auto-draft'], $c['inherit'] ); $total = (int) array_sum( $c ); $out['all'] = $total; $out['unassigned'] = max( 0, $total - array_sum( array_diff_key( $out, array( 'all' => 1 ) ) ) ); return $out; } /** Custom tabs keyed by id. */ public static function get_tabs( $pt ) { $terms = get_terms( array( 'taxonomy' => self::tax( $pt ), 'hide_empty' => false, ) ); $tabs = array(); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $t ) { $color = self::clean_color( get_term_meta( $t->term_id, 'tabwise_color', true ) ); $icon = get_term_meta( $t->term_id, 'tabwise_icon', true ); $tabs[ (string) $t->term_id ] = array( 'id' => (string) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), 'color' => $color ? $color : self::DEFAULT_COLOR, 'icon' => self::clean_icon( $icon, true ) ? $icon : 'folder', ); } } return $tabs; } /** Everything the JS needs to draw the bar. */ public static function state( $pt ) { return array( 'tabs' => (object) self::get_tabs( $pt ), 'order' => self::get_order( $pt ), 'counts' => (object) self::counts( $pt ), 'default' => self::get_default( $pt ), 'assign' => (object) self::assignments( $pt ), ); } /** Which tab each post in the current list belongs to (for Quick Edit). */ public static function assignments( $pt ) { global $wp_query; $out = array(); if ( empty( $wp_query->posts ) || ! is_array( $wp_query->posts ) ) { return $out; } $ids = array_map( 'absint', wp_list_pluck( $wp_query->posts, 'ID' ) ); $terms = wp_get_object_terms( $ids, self::tax( $pt ), array( 'fields' => 'all_with_object_id' ) ); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $t ) { $out[ (string) $t->object_id ] = (string) $t->term_id; } } return $out; } /* ------------------------------------------------------------------ * * Assets * ------------------------------------------------------------------ */ public static function assets() { $pt = get_current_screen()->post_type; $obj = get_post_type_object( $pt ); self::enqueue( array_merge( self::state( $pt ), array( 'screen' => 'list', 'postType' => $pt, 'current' => (string) self::current_tab( $pt ), 'label' => $obj ? strtolower( $obj->labels->name ) : 'pages', 'manage' => self::manage_url( $pt ), ) ) ); } /* ------------------------------------------------------------------ * * Bulk actions (JS handles these instantly; this is the no-JS fallback) * ------------------------------------------------------------------ */ public static function bulk_actions( $actions ) { $pt = get_current_screen()->post_type; $tabs = self::get_tabs( $pt ); $move = array(); foreach ( self::get_order( $pt ) as $key ) { if ( isset( $tabs[ $key ] ) ) { $move[ 'tw_move_' . $key ] = $tabs[ $key ]['name']; } } $move['tw_unassign'] = __( 'Unassigned (remove from tab)', 'tabwise' ); $actions[ __( 'Tabwise: Move to…', 'tabwise' ) ] = $move; return $actions; } public static function handle_bulk( $redirect, $action, $ids ) { if ( 0 !== strpos( $action, 'tw_' ) || ! self::can_manage() ) { return $redirect; } $term = 'tw_unassign' === $action ? 0 : absint( substr( $action, 8 ) ); self::move_posts( get_current_screen()->post_type, $ids, $term ); return $redirect; } /** Only changes tab membership; never touches the post itself. */ public static function move_posts( $pt, $ids, $term_id ) { $tax = self::tax( $pt ); if ( $term_id && ! term_exists( $term_id, $tax ) ) { return 0; } $n = 0; foreach ( array_map( 'absint', (array) $ids ) as $id ) { if ( ! $id || get_post_type( $id ) !== $pt || ! current_user_can( 'edit_post', $id ) ) { continue; } if ( ! is_wp_error( wp_set_object_terms( $id, $term_id ? array( $term_id ) : array(), $tax, false ) ) ) { ++$n; } } return $n; } /* ------------------------------------------------------------------ * * AJAX * ------------------------------------------------------------------ */ public static function ajax() { check_ajax_referer( 'tabwise', 'nonce' ); if ( ! self::can_manage() ) { wp_send_json_error( 'You do not have permission to manage tabs.', 403 ); } $pt = isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : ''; if ( ! in_array( $pt, self::post_types(), true ) ) { wp_send_json_error( 'Tabs are not enabled for this screen.', 400 ); } $tax = self::tax( $pt ); $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; $name = isset( $_POST['name'] ) ? mb_substr( trim( sanitize_text_field( wp_unslash( $_POST['name'] ) ) ), 0, 60 ) : ''; $tabs = isset( $_POST['tabs'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['tabs'] ) ) : array(); $tabs = array_values( array_filter( $tabs, function ( $t ) use ( $tax ) { return $t && term_exists( $t, $tax ); } ) ); if ( in_array( $op, array( 'rename', 'color', 'delete' ), true ) && ( ! $id || ! term_exists( $id, $tax ) ) ) { wp_send_json_error( 'That tab no longer exists.', 404 ); } switch ( $op ) { case 'create': if ( '' === $name ) { wp_send_json_error( 'Please enter a name.', 400 ); } $r = wp_insert_term( $name, $tax, array( 'slug' => 'tw-' . strtolower( wp_generate_password( 8, false, false ) ) ) ); if ( is_wp_error( $r ) ) { wp_send_json_error( $r->get_error_message(), 400 ); } update_term_meta( $r['term_id'], 'tabwise_color', self::DEFAULT_COLOR ); // New tabs go to the end of the custom tabs (left to right). $order = array_values( array_diff( self::get_order( $pt ), array( 'unassigned', 'all', (string) $r['term_id'] ) ) ); self::save_order( $pt, array_merge( $order, array( (string) $r['term_id'], 'unassigned', 'all' ) ) ); break; case 'rename': if ( '' === $name ) { wp_send_json_error( 'Please enter a name.', 400 ); } $r = wp_update_term( $id, $tax, array( 'name' => $name ) ); if ( is_wp_error( $r ) ) { wp_send_json_error( $r->get_error_message(), 400 ); } break; case 'color': $color = isset( $_POST['color'] ) ? self::clean_color( sanitize_text_field( wp_unslash( $_POST['color'] ) ) ) : ''; if ( ! $color ) { wp_send_json_error( 'Invalid color.', 400 ); } update_term_meta( $id, 'tabwise_color', $color ); break; case 'delete': // Removes the tab only; its pages become Unassigned. wp_delete_term( $id, $tax ); if ( self::get_default( $pt ) === (string) $id ) { self::set_default( $pt, '' ); } break; case 'default': $val = isset( $_POST['value'] ) ? sanitize_key( wp_unslash( $_POST['value'] ) ) : ''; if ( ! in_array( $val, array( 'all', 'unassigned' ), true ) && ! term_exists( absint( $val ), $tax ) ) { wp_send_json_error( 'That tab no longer exists.', 404 ); } self::set_default( $pt, $val ); $order = array_values( array_diff( self::get_order( $pt ), array( $val ) ) ); array_unshift( $order, $val ); // Pinned tab moves to the front. self::save_order( $pt, $order ); break; case 'reorder': $order = isset( $_POST['order'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['order'] ) ) : array(); $valid = self::get_order( $pt ); $order = array_values( array_unique( array_intersect( $order, $valid ) ) ); self::save_order( $pt, array_merge( $order, array_diff( $valid, $order ) ) ); break; case 'icon': $icon = isset( $_POST['icon'] ) ? self::clean_icon( sanitize_text_field( wp_unslash( $_POST['icon'] ) ), (bool) self::settings()['custom_icons'] ) : ''; $ids = ( $id && term_exists( $id, $tax ) ) ? array( $id ) : $tabs; if ( ! $icon || ! $ids ) { wp_send_json_error( 'Invalid icon.', 400 ); } foreach ( $ids as $tid ) { update_term_meta( $tid, 'tabwise_icon', $icon ); } break; case 'bulk_color': $color = isset( $_POST['color'] ) ? self::clean_color( sanitize_text_field( wp_unslash( $_POST['color'] ) ) ) : ''; if ( ! $color ) { wp_send_json_error( 'Invalid color.', 400 ); } foreach ( $tabs as $tid ) { update_term_meta( $tid, 'tabwise_color', $color ); } break; case 'bulk_delete': foreach ( $tabs as $tid ) { wp_delete_term( $tid, $tax ); if ( self::get_default( $pt ) === (string) $tid ) { self::set_default( $pt, '' ); } } break; case 'merge': // Move every page from the selected tabs into one tab (0 = Unassigned), then remove the emptied tabs. if ( $id && ! term_exists( $id, $tax ) ) { wp_send_json_error( 'That tab no longer exists.', 404 ); } foreach ( $tabs as $tid ) { if ( $tid === $id ) { continue; } $objects = get_objects_in_term( $tid, $tax ); if ( ! is_wp_error( $objects ) ) { self::move_posts( $pt, $objects, $id ); } wp_delete_term( $tid, $tax ); if ( self::get_default( $pt ) === (string) $tid ) { self::set_default( $pt, '' ); } } break; case 'icons': if ( ! self::settings()['custom_icons'] ) { wp_send_json_error( 'Custom icons are turned off in Tabwise Settings.', 403 ); } // Custom icons are data URIs, so sanitize_text_field() would not change them; each one is validated by clean_icon(). $list = isset( $_POST['icons'] ) ? array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['icons'] ) ) : array(); $list = array_values( array_unique( array_filter( $list, function ( $i ) { return '' !== self::clean_icon( $i, true ); } ) ) ); $list = array_slice( $list, -self::MAX_ICONS ); update_option( self::OPT_ICONS, $list, false ); wp_send_json_success( array( 'icons' => $list ) ); break; case 'export': wp_send_json_success( array( 'export' => self::export_tabs( $pt ) ) ); break; case 'import': case 'preset': if ( 'preset' === $op ) { $key = isset( $_POST['preset'] ) ? sanitize_key( wp_unslash( $_POST['preset'] ) ) : ''; $presets = array_merge( self::presets( $pt ), self::my_layouts( $pt ) ); if ( ! isset( $presets[ $key ] ) ) { wp_send_json_error( 'Unknown layout.', 400 ); } $incoming = $presets[ $key ]['tabs']; } else { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Raw JSON; every field is validated in import_tabs(). $raw = isset( $_POST['data'] ) ? (string) wp_unslash( $_POST['data'] ) : ''; if ( strlen( $raw ) > 2000000 ) { wp_send_json_error( 'That file is too large.', 400 ); } $json = json_decode( $raw, true ); $incoming = ( is_array( $json ) && isset( $json['tabs'] ) && is_array( $json['tabs'] ) ) ? $json['tabs'] : null; if ( null === $incoming ) { wp_send_json_error( 'That is not a Tabwise export file.', 400 ); } } $count = ( 'import' === $op && empty( $_POST['tabs_too'] ) ) ? 0 : self::import_tabs( $pt, $incoming, ! empty( $_POST['replace'] ), ! empty( $_POST['assign'] ) && 'import' === $op ); if ( 'import' === $op && ! empty( $_POST['layouts'] ) && ! empty( $json['layouts'] ) && is_array( $json['layouts'] ) ) { $names = wp_list_pluck( self::my_layouts( $pt ), 'name' ); foreach ( array_slice( $json['layouts'], 0, 30 ) as $layout ) { if ( ! is_array( $layout ) ) { continue; } // A layout with the same name is replaced rather than duplicated. $same = array_search( isset( $layout['name'] ) ? sanitize_text_field( (string) $layout['name'] ) : '', $names, true ); self::put_my_layout( $pt, false === $same ? '' : (string) $same, $layout ); } } $out = self::state( $pt ); $out['imported'] = $count; $out['mylayouts'] = self::my_layouts( $pt ); wp_send_json_success( $out ); break; case 'layout_save': // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Raw JSON; every field is cleaned in put_my_layout(). $raw = isset( $_POST['data'] ) ? (string) wp_unslash( $_POST['data'] ) : ''; $layout = strlen( $raw ) <= 500000 ? json_decode( $raw, true ) : null; $lid = isset( $_POST['layout'] ) ? sanitize_key( wp_unslash( $_POST['layout'] ) ) : ''; if ( ! is_array( $layout ) || '' === self::put_my_layout( $pt, $lid, $layout ) ) { wp_send_json_error( 'Please give the layout a name.', 400 ); } wp_send_json_success( array( 'mylayouts' => self::my_layouts( $pt ) ) ); break; case 'layout_delete': $lid = isset( $_POST['layout'] ) ? sanitize_key( wp_unslash( $_POST['layout'] ) ) : ''; $mine = self::my_layouts( $pt ); unset( $mine[ $lid ] ); self::save_my_layouts( $pt, $mine ); wp_send_json_success( array( 'mylayouts' => self::my_layouts( $pt ) ) ); break; case 'colors': $list = isset( $_POST['colors'] ) ? array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['colors'] ) ) : array(); $list = array_slice( array_values( array_unique( array_filter( array_map( array( __CLASS__, 'clean_color' ), $list ) ) ) ), 0, 21 ); update_option( self::OPT_COLORS, $list, false ); wp_send_json_success( array( 'saved' => $list ) ); break; case 'move': $ids = isset( $_POST['ids'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['ids'] ) ) : array(); self::move_posts( $pt, $ids, $id ); break; default: wp_send_json_error( 'Unknown action.', 400 ); } $out = self::state( $pt ); if ( 'create' === $op ) { $out['created'] = (string) $r['term_id']; } wp_send_json_success( $out ); } /** * Clean a list of layout tabs (name, color, icon only). * * @param mixed $tabs Incoming list. */ private static function clean_layout_tabs( $tabs ) { $custom = (bool) self::settings()['custom_icons']; $out = array(); foreach ( array_slice( is_array( $tabs ) ? $tabs : array(), 0, 50 ) as $tab ) { if ( ! is_array( $tab ) ) { continue; } $name = mb_substr( trim( sanitize_text_field( isset( $tab['name'] ) ? (string) $tab['name'] : '' ) ), 0, 60 ); if ( '' === $name ) { continue; } $color = self::clean_color( isset( $tab['color'] ) ? (string) $tab['color'] : '' ); $icon = self::clean_icon( isset( $tab['icon'] ) ? (string) $tab['icon'] : '', $custom ); $out[] = array( 'name' => $name, 'color' => $color ? $color : self::DEFAULT_COLOR, 'icon' => $icon ? $icon : 'folder', ); } return $out; } /** Layouts the user saved for one post type, keyed by id. */ public static function my_layouts( $pt ) { $all = get_option( self::OPT_LAYOUTS, array() ); $all = is_array( $all ) && isset( $all[ $pt ] ) && is_array( $all[ $pt ] ) ? $all[ $pt ] : array(); $out = array(); foreach ( $all as $id => $l ) { if ( is_array( $l ) && preg_match( '/^my-[a-z0-9]{8}$/', (string) $id ) ) { $out[ $id ] = array( 'name' => isset( $l['name'] ) ? (string) $l['name'] : '', 'desc' => isset( $l['desc'] ) ? (string) $l['desc'] : '', 'tabs' => self::clean_layout_tabs( isset( $l['tabs'] ) ? $l['tabs'] : array() ), 'mine' => true, ); } } return $out; } private static function save_my_layouts( $pt, $layouts ) { $all = get_option( self::OPT_LAYOUTS, array() ); $all = is_array( $all ) ? $all : array(); $all[ $pt ] = array_slice( $layouts, -30, null, true ); update_option( self::OPT_LAYOUTS, $all, false ); } /** * Add or replace a saved layout. Returns its id. * * @param string $pt Post type. * @param string $id Existing id, or '' for a new layout. * @param array $layout Raw layout (name, desc, tabs). */ private static function put_my_layout( $pt, $id, $layout ) { $name = mb_substr( trim( sanitize_text_field( isset( $layout['name'] ) ? (string) $layout['name'] : '' ) ), 0, 60 ); if ( '' === $name ) { return ''; } $mine = self::my_layouts( $pt ); if ( ! isset( $mine[ $id ] ) ) { $id = 'my-' . strtolower( wp_generate_password( 8, false, false ) ); } $mine[ $id ] = array( 'name' => $name, 'desc' => mb_substr( trim( sanitize_text_field( isset( $layout['desc'] ) ? (string) $layout['desc'] : '' ) ), 0, 120 ), 'tabs' => self::clean_layout_tabs( isset( $layout['tabs'] ) ? $layout['tabs'] : array() ), ); self::save_my_layouts( $pt, $mine ); return $id; } /** Tabs for one post type as a portable list. Pages are referenced by slug so they can match on another site. */ private static function export_tabs( $pt ) { $tabs = self::get_tabs( $pt ); $order = array_values( array_intersect( self::get_order( $pt ), array_keys( $tabs ) ) ); $out = array(); foreach ( $order as $id ) { $objects = get_objects_in_term( (int) $id, self::tax( $pt ) ); $slugs = array(); if ( ! is_wp_error( $objects ) ) { foreach ( $objects as $oid ) { $path = 'page' === $pt ? get_page_uri( (int) $oid ) : get_post_field( 'post_name', (int) $oid ); if ( $path ) { $slugs[] = $path; } } } $out[] = array( 'name' => $tabs[ $id ]['name'], 'color' => $tabs[ $id ]['color'], 'icon' => $tabs[ $id ]['icon'], 'default' => self::get_default( $pt ) === (string) $id, 'pages' => $slugs, ); } return array( 'tabwise' => 1, 'type' => $pt, 'tabs' => $out, 'layouts' => array_values( array_map( function ( $l ) { unset( $l['mine'] ); return $l; }, self::my_layouts( $pt ) ) ), ); } /** * Create or update tabs from an export file or a layout. * Tabs with a matching name are updated rather than duplicated. * * @param string $pt Post type. * @param array $tabs Incoming tabs. * @param bool $replace Delete existing tabs first (pages become Unassigned). * @param bool $assign Also put pages back in their tabs, matched by slug. * @return int Number of tabs imported. */ private static function import_tabs( $pt, $tabs, $replace, $assign ) { $tax = self::tax( $pt ); $custom = (bool) self::settings()['custom_icons']; if ( $replace ) { foreach ( array_keys( self::get_tabs( $pt ) ) as $id ) { wp_delete_term( (int) $id, $tax ); } self::set_default( $pt, '' ); } $order = array_values( array_diff( self::get_order( $pt ), array( 'unassigned', 'all' ) ) ); $count = 0; foreach ( array_slice( $tabs, 0, 100 ) as $tab ) { if ( ! is_array( $tab ) ) { continue; } $name = mb_substr( trim( sanitize_text_field( isset( $tab['name'] ) ? (string) $tab['name'] : '' ) ), 0, 60 ); if ( '' === $name ) { continue; } $term = get_term_by( 'name', $name, $tax ); if ( ! $term ) { $term = get_term_by( 'name', esc_html( $name ), $tax ); // Names saved with "&" are stored as "&". } if ( $term ) { $id = (int) $term->term_id; } else { $r = wp_insert_term( $name, $tax, array( 'slug' => 'tw-' . strtolower( wp_generate_password( 8, false, false ) ) ) ); if ( is_wp_error( $r ) ) { continue; } $id = (int) $r['term_id']; } $color = self::clean_color( isset( $tab['color'] ) ? (string) $tab['color'] : '' ); $icon = self::clean_icon( isset( $tab['icon'] ) ? (string) $tab['icon'] : '', $custom ); update_term_meta( $id, 'tabwise_color', $color ? $color : self::DEFAULT_COLOR ); update_term_meta( $id, 'tabwise_icon', $icon ? $icon : 'folder' ); if ( ! empty( $tab['default'] ) ) { self::set_default( $pt, (string) $id ); } if ( $assign && ! empty( $tab['pages'] ) && is_array( $tab['pages'] ) ) { $ids = array(); foreach ( array_slice( $tab['pages'], 0, 2000 ) as $slug ) { $slug = sanitize_text_field( (string) $slug ); $post = 'page' === $pt ? get_page_by_path( $slug, OBJECT, 'page' ) : null; if ( ! $post && 'page' !== $pt ) { $found = get_posts( array( 'name' => sanitize_title( $slug ), 'post_type' => $pt, 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', ) ); $post = $found ? get_post( $found[0] ) : null; } if ( $post ) { $ids[] = $post->ID; } } self::move_posts( $pt, $ids, $id ); } $order = array_values( array_diff( $order, array( (string) $id ) ) ); $order[] = (string) $id; ++$count; } self::save_order( $pt, array_merge( $order, array( 'unassigned', 'all' ) ) ); return $count; } /** * Ready-made tab layouts. Pages and Posts each get their own list. * Only loaded on the Manage Tabs screen. * * @param string $pt Post type. */ public static function presets( $pt = 'page' ) { $t = function ( $name, $color, $icon ) { return array( 'name' => $name, 'color' => $color, 'icon' => $icon, ); }; $main = $t( __( 'Main Pages', 'tabwise' ), '#3b82f6', 'home' ); $legal = $t( __( 'Legal & Policies', 'tabwise' ), '#64748b', 'lock' ); $util = $t( __( 'Utility & Drafts', 'tabwise' ), '#8c8f94', 'settings' ); $ideas = $t( __( 'Ideas & Drafts', 'tabwise' ), '#8c8f94', 'dashicons-lightbulb' ); if ( 'post' === $pt ) { return array( 'blog' => array( 'name' => __( 'General Blog', 'tabwise' ), 'desc' => __( 'A mix of news, guides and reviews.', 'tabwise' ), 'tabs' => array( $t( __( 'News', 'tabwise' ), '#3b82f6', 'megaphone' ), $t( __( 'Guides & How-Tos', 'tabwise' ), '#14b8a6', 'book' ), $t( __( 'Reviews', 'tabwise' ), '#eab308', 'star' ), $t( __( 'Opinion', 'tabwise' ), '#a855f7', 'dashicons-format-quote' ), $t( __( 'Announcements', 'tabwise' ), '#ef4444', 'flag' ), $ideas ), ), 'company' => array( 'name' => __( 'Company Blog', 'tabwise' ), 'desc' => __( 'Business news, updates and success stories.', 'tabwise' ), 'tabs' => array( $t( __( 'Company News', 'tabwise' ), '#3b82f6', 'building' ), $t( __( 'Product Updates', 'tabwise' ), '#a855f7', 'bolt' ), $t( __( 'Case Studies', 'tabwise' ), '#22c55e', 'chart' ), $t( __( 'Industry Insights', 'tabwise' ), '#0ea5e9', 'dashicons-lightbulb' ), $t( __( 'Events', 'tabwise' ), '#f97316', 'calendar' ), $t( __( 'Press', 'tabwise' ), '#ef4444', 'megaphone' ), $ideas ), ), 'local' => array( 'name' => __( 'Local Business Blog', 'tabwise' ), 'desc' => __( 'Contractors and service companies.', 'tabwise' ), 'tabs' => array( $t( __( 'Projects', 'tabwise' ), '#a855f7', 'image' ), $t( __( 'Tips & Advice', 'tabwise' ), '#14b8a6', 'dashicons-lightbulb' ), $t( __( 'Seasonal Guides', 'tabwise' ), '#f97316', 'calendar' ), $t( __( 'Customer Stories', 'tabwise' ), '#eab308', 'star' ), $t( __( 'Service Spotlights', 'tabwise' ), '#3b82f6', 'wrench' ), $t( __( 'Company News', 'tabwise' ), '#ef4444', 'megaphone' ), $ideas ), ), 'tech' => array( 'name' => __( 'Tech & Tutorials', 'tabwise' ), 'desc' => __( 'Software, gadgets and how-to content.', 'tabwise' ), 'tabs' => array( $t( __( 'Tutorials', 'tabwise' ), '#14b8a6', 'book' ), $t( __( 'How-To Guides', 'tabwise' ), '#3b82f6', 'wrench' ), $t( __( 'Reviews', 'tabwise' ), '#eab308', 'star' ), $t( __( 'News', 'tabwise' ), '#ef4444', 'megaphone' ), $t( __( 'Tools & Resources', 'tabwise' ), '#a855f7', 'dashicons-download' ), $ideas ), ), 'food' => array( 'name' => __( 'Food & Recipes', 'tabwise' ), 'desc' => __( 'Recipe and cooking blogs.', 'tabwise' ), 'tabs' => array( $t( __( 'Recipes', 'tabwise' ), '#f97316', 'dashicons-food' ), $t( __( 'Meal Plans', 'tabwise' ), '#22c55e', 'calendar' ), $t( __( 'Tips & Techniques', 'tabwise' ), '#0ea5e9', 'dashicons-lightbulb' ), $t( __( 'Reviews', 'tabwise' ), '#eab308', 'star' ), $t( __( 'Seasonal', 'tabwise' ), '#ef4444', 'dashicons-carrot' ), $ideas ), ), 'travel' => array( 'name' => __( 'Travel', 'tabwise' ), 'desc' => __( 'Destinations, trips and travel tips.', 'tabwise' ), 'tabs' => array( $t( __( 'Destinations', 'tabwise' ), '#ef4444', 'pin' ), $t( __( 'Trip Reports', 'tabwise' ), '#3b82f6', 'dashicons-airplane' ), $t( __( 'Travel Guides', 'tabwise' ), '#14b8a6', 'book' ), $t( __( 'Tips', 'tabwise' ), '#eab308', 'dashicons-lightbulb' ), $t( __( 'Gear', 'tabwise' ), '#a855f7', 'briefcase' ), $t( __( 'Photo Stories', 'tabwise' ), '#f97316', 'image' ) ), ), 'lifestyle' => array( 'name' => __( 'Lifestyle & Personal', 'tabwise' ), 'desc' => __( 'Personal blogs, home, wellness and style.', 'tabwise' ), 'tabs' => array( $t( __( 'Life & Stories', 'tabwise' ), '#a855f7', 'dashicons-format-quote' ), $t( __( 'Health & Wellness', 'tabwise' ), '#22c55e', 'heart' ), $t( __( 'Home', 'tabwise' ), '#f97316', 'home' ), $t( __( 'Style', 'tabwise' ), '#ec4899', 'dashicons-admin-appearance' ), $t( __( 'Money', 'tabwise' ), '#eab308', 'dashicons-money-alt' ), $ideas ), ), 'media' => array( 'name' => __( 'Podcast & Video', 'tabwise' ), 'desc' => __( 'Episodes, videos and show notes.', 'tabwise' ), 'tabs' => array( $t( __( 'Episodes', 'tabwise' ), '#a855f7', 'dashicons-microphone' ), $t( __( 'Videos', 'tabwise' ), '#ef4444', 'dashicons-format-video' ), $t( __( 'Show Notes', 'tabwise' ), '#0ea5e9', 'file' ), $t( __( 'Guests', 'tabwise' ), '#22c55e', 'users' ), $t( __( 'Behind the Scenes', 'tabwise' ), '#f97316', 'dashicons-camera' ), $t( __( 'Announcements', 'tabwise' ), '#eab308', 'flag' ) ), ), ); } return array( 'business' => array( 'name' => __( 'Business & Services', 'tabwise' ), 'desc' => __( 'Company sites that sell services.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Services', 'tabwise' ), '#14b8a6', 'wrench' ), $t( __( 'About & Team', 'tabwise' ), '#a855f7', 'users' ), $t( __( 'Contact', 'tabwise' ), '#22c55e', 'phone' ), $t( __( 'Landing Pages', 'tabwise' ), '#f97316', 'megaphone' ), $legal, $util ), ), 'local' => array( 'name' => __( 'Local Contractor', 'tabwise' ), 'desc' => __( 'Home services, trades and contractors.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Residential', 'tabwise' ), '#eab308', 'home' ), $t( __( 'Commercial', 'tabwise' ), '#14b8a6', 'building' ), $t( __( 'Service Areas', 'tabwise' ), '#ef4444', 'pin' ), $t( __( 'Projects & Gallery', 'tabwise' ), '#a855f7', 'image' ), $t( __( 'Reviews', 'tabwise' ), '#f97316', 'star' ), $legal, $util ), ), 'store' => array( 'name' => __( 'Online Store', 'tabwise' ), 'desc' => __( 'Shops and ecommerce sites.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Shop & Products', 'tabwise' ), '#22c55e', 'cart' ), $t( __( 'Collections', 'tabwise' ), '#a855f7', 'grid' ), $t( __( 'Account & Checkout', 'tabwise' ), '#0ea5e9', 'dashicons-id' ), $t( __( 'Promotions', 'tabwise' ), '#ef4444', 'tag' ), $t( __( 'Customer Help', 'tabwise' ), '#f97316', 'dashicons-sos' ), $t( __( 'Store Policies', 'tabwise' ), '#64748b', 'shield' ), $util ), ), 'blog' => array( 'name' => __( 'Blog or Magazine', 'tabwise' ), 'desc' => __( 'Content sites, for the Pages screen.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Topics & Categories', 'tabwise' ), '#a855f7', 'grid' ), $t( __( 'Resources', 'tabwise' ), '#14b8a6', 'book' ), $t( __( 'About & Authors', 'tabwise' ), '#0ea5e9', 'users' ), $t( __( 'Newsletter', 'tabwise' ), '#f97316', 'mail' ), $legal, $util ), ), 'agency' => array( 'name' => __( 'Agency or Portfolio', 'tabwise' ), 'desc' => __( 'Studios, freelancers and creatives.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Services', 'tabwise' ), '#14b8a6', 'wrench' ), $t( __( 'Work & Case Studies', 'tabwise' ), '#a855f7', 'dashicons-portfolio' ), $t( __( 'Team', 'tabwise' ), '#0ea5e9', 'users' ), $t( __( 'Careers', 'tabwise' ), '#f97316', 'briefcase' ), $legal, $util ), ), 'restaurant' => array( 'name' => __( 'Restaurant & Hospitality', 'tabwise' ), 'desc' => __( 'Restaurants, cafes, hotels and venues.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Menus', 'tabwise' ), '#f97316', 'dashicons-food' ), $t( __( 'Locations', 'tabwise' ), '#ef4444', 'pin' ), $t( __( 'Events', 'tabwise' ), '#a855f7', 'calendar' ), $t( __( 'Reservations', 'tabwise' ), '#22c55e', 'dashicons-schedule' ), $t( __( 'Gallery', 'tabwise' ), '#0ea5e9', 'image' ), $legal ), ), 'saas' => array( 'name' => __( 'Software & SaaS', 'tabwise' ), 'desc' => __( 'Apps, software and tech products.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Features', 'tabwise' ), '#a855f7', 'bolt' ), $t( __( 'Pricing', 'tabwise' ), '#22c55e', 'dashicons-money-alt' ), $t( __( 'Docs & Help', 'tabwise' ), '#0ea5e9', 'book' ), $t( __( 'Integrations', 'tabwise' ), '#14b8a6', 'layers' ), $t( __( 'Company', 'tabwise' ), '#f97316', 'building' ), $legal, $util ), ), 'nonprofit' => array( 'name' => __( 'Nonprofit & Community', 'tabwise' ), 'desc' => __( 'Charities, churches, clubs and causes.', 'tabwise' ), 'tabs' => array( $main, $t( __( 'Programs', 'tabwise' ), '#ef4444', 'heart' ), $t( __( 'Get Involved', 'tabwise' ), '#22c55e', 'users' ), $t( __( 'Donate', 'tabwise' ), '#eab308', 'dashicons-money-alt' ), $t( __( 'Events', 'tabwise' ), '#a855f7', 'calendar' ), $t( __( 'News', 'tabwise' ), '#0ea5e9', 'megaphone' ), $legal ), ), ); } private static function set_default( $pt, $val ) { $d = get_option( self::OPT_DEFAULT, array() ); $d = is_array( $d ) ? $d : array(); if ( '' === $val ) { unset( $d[ $pt ] ); } else { $d[ $pt ] = (string) $val; } update_option( self::OPT_DEFAULT, $d, false ); } /* ------------------------------------------------------------------ * * Settings → Tabwise * ------------------------------------------------------------------ */ /** * Tabwise lives inside the Pages menu (and the Posts menu when enabled): * Pages → Manage Tabs and Pages → Tabwise Settings. No extra top-level menu. */ public static function settings_menu() { $cap = self::can_manage() ? 'read' : 'manage_options'; $hook = add_submenu_page( 'edit.php?post_type=page', __( 'Manage Page Tabs', 'tabwise' ), __( 'Manage Tabs', 'tabwise' ), $cap, 'tabwise-manage', array( __CLASS__, 'manage_page' ) ); add_action( 'load-' . $hook, array( __CLASS__, 'load_manage_screen' ) ); add_submenu_page( 'edit.php?post_type=page', __( 'Tabwise Settings', 'tabwise' ), __( 'Tabwise Settings', 'tabwise' ), 'manage_options', 'tabwise', array( __CLASS__, 'settings_page' ) ); if ( in_array( 'post', self::post_types(), true ) ) { $hook = add_submenu_page( 'edit.php', __( 'Manage Post Tabs', 'tabwise' ), __( 'Manage Tabs', 'tabwise' ), $cap, 'tabwise-manage-post', array( __CLASS__, 'manage_page' ) ); add_action( 'load-' . $hook, array( __CLASS__, 'load_manage_screen' ) ); // Same settings page, linked from the Posts menu too. add_submenu_page( 'edit.php', __( 'Tabwise Settings', 'tabwise' ), __( 'Tabwise Settings', 'tabwise' ), 'manage_options', 'edit.php?post_type=page&page=tabwise' ); } } /** Which post type a Manage Tabs screen is for. */ private static function manage_type() { $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification return 'tabwise-manage-post' === $page ? 'post' : 'page'; } /** URL of the Manage Tabs screen for a post type. */ public static function manage_url( $pt ) { return 'post' === $pt ? admin_url( 'edit.php?page=tabwise-manage-post' ) : admin_url( 'edit.php?post_type=page&page=tabwise-manage' ); } public static function load_manage_screen() { add_action( 'admin_enqueue_scripts', array( __CLASS__, 'manage_assets' ) ); } /** Manage Tabs: one panel per enabled post type, built by the script. */ public static function manage_page() { if ( ! self::can_manage() ) { return; } ?>

$obj ? $obj->labels->name : $pt, 'list' => admin_url( 'page' === $pt ? 'edit.php?post_type=page' : 'edit.php' ), ) ); } self::enqueue( array( 'screen' => 'manage', 'types' => $types, 'postType' => self::manage_type(), 'manage' => self::manage_url( self::manage_type() ), 'settings' => self::settings_url(), 'layouts' => self::presets( self::manage_type() ), 'mylayouts' => self::my_layouts( self::manage_type() ), ) ); } /** * Load the shared script and stylesheet with their settings. * * @param array $data Screen-specific data for the script. */ private static function enqueue( $data ) { $url = plugin_dir_url( TABWISE_FILE ); $dir = plugin_dir_path( TABWISE_FILE ) . 'assets/'; // Cache-bust by file time so browsers always load the current files. $css = TABWISE_VERSION . '.' . (int) filemtime( $dir . 'tabwise.css' ); $js = TABWISE_VERSION . '.' . (int) filemtime( $dir . 'tabwise.js' ); wp_enqueue_style( 'tabwise', $url . 'assets/tabwise.css', array( 'dashicons' ), $css ); wp_enqueue_script( 'tabwise', $url . 'assets/tabwise.js', array(), $js, true ); wp_localize_script( 'tabwise', 'Tabwise', array_merge( array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'tabwise' ), 'qv' => self::QV, 'presets' => self::PRESETS, 'saved' => self::saved_colors(), 'icons' => self::saved_icons(), 'visible' => (int) self::settings()['visible'], 'custom' => (int) self::settings()['custom_icons'], 'manage' => self::manage_url( 'page' ), ), $data ) ); } public static function register_settings() { register_setting( 'tabwise', self::OPT_SETTINGS, array( 'type' => 'array', 'sanitize_callback' => function ( $in ) { $in = is_array( $in ) ? $in : array(); $roles = isset( $in['roles'] ) ? array_map( 'sanitize_key', (array) $in['roles'] ) : array(); $roles = array_values( array_intersect( $roles, array_keys( wp_roles()->get_names() ) ) ); $roles = array_values( array_unique( array_merge( array( 'administrator' ), $roles ) ) ); return array( 'posts' => empty( $in['posts'] ) ? 0 : 1, 'cleanup' => empty( $in['cleanup'] ) ? 0 : 1, 'roles' => $roles, 'visible' => isset( $in['visible'] ) ? min( 10, max( 3, absint( $in['visible'] ) ) ) : 5, 'custom_icons' => empty( $in['custom_icons'] ) ? 0 : 1, ); }, ) ); } public static function settings_url() { return admin_url( 'edit.php?post_type=page&page=tabwise' ); } public static function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Settings', 'tabwise' ) . '' ); return $links; } public static function row_meta( $meta, $file ) { if ( plugin_basename( TABWISE_FILE ) === $file ) { $meta[] = '' . esc_html__( 'Open Pages', 'tabwise' ) . ''; $meta[] = '' . esc_html__( 'Settings', 'tabwise' ) . ''; } return $meta; } public static function settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $s = self::settings(); $name = self::OPT_SETTINGS; $roles = wp_roles()->get_names(); ?>

.