'ailsug-keyword',
'plural' => 'ailsug-keywords',
'ajax' => false,
)
);
}
/**
* Read and whitelist the current filters.
*
* @return array{view:string,post_type:string,s:string,orderby:string,order:string}
*/
public static function filters() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only filters.
$view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : 'all';
$post_type = isset( $_GET['post_type_filter'] ) ? sanitize_key( wp_unslash( $_GET['post_type_filter'] ) ) : '';
$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
$orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'inbound_count';
$order = isset( $_GET['order'] ) && 'desc' === strtolower( sanitize_key( wp_unslash( $_GET['order'] ) ) ) ? 'DESC' : 'ASC';
// phpcs:enable
return array(
'view' => in_array( $view, array( 'all', 'nokeyword', 'orphan', 'low' ), true ) ? $view : 'all',
'post_type' => $post_type,
's' => $search,
'orderby' => in_array( $orderby, array( 'title', 'primary_keyword', 'inbound_count', 'outbound_count', 'word_count' ), true ) ? $orderby : 'inbound_count',
'order' => $order,
);
}
/**
* Build a WHERE clause for filters.
*
* @param array $f Filters.
* @return array{0:string,1:array} SQL and params.
*/
/**
* Static WHERE clause SQL format string with placeholders.
*
* @return string
*/
public static function where_clause() {
return '( %s = \'all\' OR ( %s = \'nokeyword\' AND primary_keyword = \'\' ) OR ( %s = \'orphan\' AND inbound_count = 0 ) OR ( %s = \'low\' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = \'\' OR post_type = %s ) AND ( %s = \'\' OR title LIKE %s OR primary_keyword LIKE %s )';
}
/**
* Parameter array for the static WHERE clause.
*
* @param array $f Filters.
* @return array
*/
public static function where_params( array $f ) {
global $wpdb;
$view = (string) $f['view'];
$post_type = (string) $f['post_type'];
$search = ( '' !== $f['s'] ) ? '%' . $wpdb->esc_like( $f['s'] ) . '%' : '';
return array(
$view,
$view,
$view,
$view,
$post_type,
$post_type,
$search,
$search,
$search,
);
}
/**
* WHERE clause for filters.
*
* @param array $f Filters.
* @return array{0:string,1:array}
*/
public static function where( array $f ) {
return array( self::where_clause(), self::where_params( $f ) );
}
/**
* Columns.
*
* @return array
*/
public function get_columns() {
return array(
'title' => __( 'Page', 'linkcortex' ),
'primary_keyword' => __( 'Primary keyword', 'linkcortex' ),
'source' => __( 'Keyword from', 'linkcortex' ),
'inbound_count' => __( 'Links in', 'linkcortex' ),
'outbound_count' => __( 'Links out', 'linkcortex' ),
'word_count' => __( 'Words', 'linkcortex' ),
);
}
/**
* Sortable columns.
*
* @return array
*/
protected function get_sortable_columns() {
return array(
'title' => array( 'title', false ),
'primary_keyword' => array( 'primary_keyword', false ),
'inbound_count' => array( 'inbound_count', false ),
'outbound_count' => array( 'outbound_count', true ),
'word_count' => array( 'word_count', true ),
);
}
/**
* Views (All / No keyword / Orphans / Few links in).
*
* @return array
*/
protected function get_views() {
global $wpdb;
$t = Activator::tables();
$f = self::filters();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table.
$counts = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS total, SUM(primary_keyword = '') AS nokeyword, SUM(inbound_count = 0) AS orphan, SUM(inbound_count BETWEEN 1 AND 2) AS low FROM %i", $t['index'] ), ARRAY_A );
$labels = array(
'all' => __( 'All', 'linkcortex' ),
'nokeyword' => __( 'No keyword yet', 'linkcortex' ),
'orphan' => __( 'Orphan pages (no links in)', 'linkcortex' ),
'low' => __( 'Few links in (1–2)', 'linkcortex' ),
);
$keys = array(
'all' => 'total',
'nokeyword' => 'nokeyword',
'orphan' => 'orphan',
'low' => 'low',
);
$views = array();
foreach ( $labels as $view => $label ) {
$url = Menu::url( 'keywords', 'all' === $view ? array() : array( 'view' => $view ) );
$views[ $view ] = sprintf(
'%3$s (%4$s)',
esc_url( $url ),
$f['view'] === $view ? ' class="current" aria-current="page"' : '',
esc_html( $label ),
esc_html( number_format_i18n( (int) ( $counts[ $keys[ $view ] ] ?? 0 ) ) )
);
}
return $views;
}
/**
* Post type filter dropdown.
*
* @param string $which top|bottom.
* @return void
*/
protected function extra_tablenav( $which ) {
if ( 'top' !== $which ) {
return;
}
$f = self::filters();
echo '
';
echo '';
echo '';
submit_button( __( 'Filter', 'linkcortex' ), '', 'filter_action', false );
echo '
';
}
/**
* Load items.
*
* @return void
*/
public function prepare_items() {
global $wpdb;
$t = Activator::tables();
$f = self::filters();
$page = $this->get_pagenum();
$offset = ( $page - 1 ) * self::PER_PAGE;
$w_params = self::where_params( $f );
$c_args = array_merge( array( $t['index'] ), $w_params );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table.
$total = (int) $wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic filter parameters passed as array.
$wpdb->prepare(
"SELECT COUNT(*) FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s )",
$c_args
)
);
$orderby = in_array( $f['orderby'], array( 'title', 'primary_keyword', 'inbound_count', 'outbound_count', 'word_count' ), true ) ? $f['orderby'] : 'title';
$order = ( 'asc' === strtolower( $f['order'] ) ) ? 'ASC' : 'DESC';
$list_args = array_merge( array( $t['index'] ), $w_params, array( self::PER_PAGE, $offset ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Direct query on own custom table with dynamic filter parameters.
switch ( "{$orderby}_{$order}" ) {
case 'primary_keyword_ASC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY primary_keyword ASC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'primary_keyword_DESC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY primary_keyword DESC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'inbound_count_ASC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY inbound_count ASC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'inbound_count_DESC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY inbound_count DESC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'outbound_count_ASC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY outbound_count ASC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'outbound_count_DESC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY outbound_count DESC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'word_count_ASC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY word_count ASC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'word_count_DESC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY word_count DESC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'title_DESC':
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY title DESC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
case 'title_ASC':
default:
$this->items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = 'all' OR ( %s = 'nokeyword' AND primary_keyword = '' ) OR ( %s = 'orphan' AND inbound_count = 0 ) OR ( %s = 'low' AND inbound_count BETWEEN 1 AND 2 ) ) AND ( %s = '' OR post_type = %s ) AND ( %s = '' OR title LIKE %s OR primary_keyword LIKE %s ) ORDER BY title ASC, post_id ASC LIMIT %d OFFSET %d", $list_args ), ARRAY_A );
break;
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns(), 'title' );
$this->set_pagination_args(
array(
'total_items' => $total,
'per_page' => self::PER_PAGE,
)
);
}
/**
* Empty state.
*
* @return void
*/
public function no_items() {
esc_html_e( 'No pages here yet. Click "Update page index" above to read your site (free, no AI).', 'linkcortex' );
}
/**
* Title column.
*
* @param array $item Row.
* @return string
*/
protected function column_title( $item ) {
$id = (int) $item['post_id'];
$title = '' !== $item['title'] ? $item['title'] : __( '(no title)', 'linkcortex' );
$out = '' . esc_html( $title ) . '';
$type = get_post_type_object( $item['post_type'] );
$meta = array( $type ? $type->labels->singular_name : $item['post_type'] );
if ( (int) $item['is_noindex'] ) {
$meta[] = __( 'noindex', 'linkcortex' );
}
if ( (int) $item['is_builder'] ) {
$meta[] = __( 'page builder (view only)', 'linkcortex' );
}
$out .= '
' . esc_html( implode( ' · ', $meta ) ) . '';
$actions = array();
if ( current_user_can( 'edit_post', $id ) ) {
$actions['edit'] = '' . esc_html__( 'Edit', 'linkcortex' ) . '';
}
$actions['view'] = '' . esc_html__( 'View', 'linkcortex' ) . '';
return $out . $this->row_actions( $actions );
}
/**
* Keyword column (inline editable).
*
* @param array $item Row.
* @return string
*/
protected function column_primary_keyword( $item ) {
$id = (int) $item['post_id'];
if ( ! current_user_can( 'edit_post', $id ) ) {
return '' !== $item['primary_keyword'] ? esc_html( $item['primary_keyword'] ) : '—';
}
$input_id = 'ailsug-kw-' . $id;
return sprintf(
'
',
$id,
esc_attr( $input_id ),
/* translators: %s: page title. */
esc_html( sprintf( __( 'Primary keyword for %s', 'linkcortex' ), $item['title'] ) ),
esc_attr( $item['primary_keyword'] ),
esc_attr__( 'Not set yet', 'linkcortex' ),
esc_html__( 'Save', 'linkcortex' )
);
}
/**
* Source badge.
*
* @param array $item Row.
* @return string
*/
protected function column_source( $item ) {
return self::source_badge( (string) $item['keyword_source'] );
}
/**
* Badge markup for a keyword source (text, not color only).
*
* @param string $source Source.
* @return string
*/
public static function source_badge( $source ) {
$labels = array(
'manual' => __( 'You', 'linkcortex' ),
'seo_plugin' => __( 'SEO plugin', 'linkcortex' ),
'ai' => __( 'AI', 'linkcortex' ),
);
if ( ! isset( $labels[ $source ] ) ) {
return '' . esc_html__( 'Not set', 'linkcortex' ) . '';
}
return '' . esc_html( $labels[ $source ] ) . '';
}
/**
* Inbound column (orphans flagged in text).
*
* @param array $item Row.
* @return string
*/
protected function column_inbound_count( $item ) {
$n = (int) $item['inbound_count'];
return 0 === $n ? '' . esc_html__( '0 · orphan', 'linkcortex' ) . '' : esc_html( number_format_i18n( $n ) );
}
/**
* Default column.
*
* @param array $item Row.
* @param string $column_name Column.
* @return string
*/
protected function column_default( $item, $column_name ) {
return esc_html( number_format_i18n( (int) ( $item[ $column_name ] ?? 0 ) ) );
}
}