position, from other add-ons.
*
* @return array
*/
public static function declare_menu_position( $positions ): array {
$positions = is_array( $positions ) ? $positions : array();
$positions[ self::PAGE ] = 70;
return $positions;
}
/**
* Adds the submenu page.
*
* @return void
*/
public static function register_page(): void {
add_submenu_page(
'creator-assistant-hub',
__( 'Categories', 'creator-assistant-categories' ),
__( 'Categories', 'creator-assistant-categories' ),
self::CAPABILITY,
self::PAGE,
array( self::class, 'render' )
);
}
/**
* Indents the levels of the tree on this page. Handles admin_enqueue_scripts.
*
* WordPress admin lists have no indent, so the nested lists of
* render_tree() would look like one flat list. Inline on WordPress's own
* admin style (common): a stylesheet of ours would be loaded for two rules.
*
* @param mixed $hook_suffix The admin page (any value another plugin passes).
*
* @return void
*/
public static function enqueue_styles( $hook_suffix ): void {
$suffix = '_page_' . self::PAGE;
// The prefix comes from the title of the Hub menu, which is translated.
if ( ! is_string( $hook_suffix ) || substr( $hook_suffix, -strlen( $suffix ) ) !== $suffix ) {
return;
}
wp_add_inline_style(
'common',
'.creator-assistant-categories-tree .creator-assistant-categories-tree { margin-left: 1.5em; }
.creator-assistant-categories-tree details > ul { list-style: disc; margin-left: 2.5em; }'
);
}
/**
* Renders the page.
*
* @return void
*/
public static function render(): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
return;
}
// A dead clustering task shows up on the next page load even when
// WP-Cron runs late.
Run_Monitor::check_throttled();
$state = Run_State::get();
$settings = Settings::get();
$notice = isset( $_GET[ self::NOTICE_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::NOTICE_ARG ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
// Checked here, not only when the analysis starts: the page can say
// what is missing before the user fills in the form for nothing.
$hub_ready = Hub_Settings::is_service_active();
// What the application would delete and keep, read now and not saved
// with the preview: the categories can change between the analysis and
// the application. Only for a preview: after the application there is
// nothing left to tell.
$plan = Run_State::READY === $state['status'] && is_array( $state['tree'] ?? null ) ? Category_Planner::plan( $state['tree'] ) : null;
require __DIR__ . '/views/admin-page.php';
}
/**
* Prints a tree of categories as nested lists, each node with its number
* of articles and the titles the model saw when it named it.
*
* The tree comes from the clustering service and from the LLM, so nothing
* in it is trusted: a node or a member of the wrong type is skipped, every
* string is escaped where it is printed.
*
* @param array $nodes Named nodes, from the state.
* @param array $plan Optional. Category_Planner::plan()['nodes'], to say next to each node what happens to its category. Default none (no label).
*
* @return void
*/
public static function render_tree( array $nodes, array $plan = array() ): void {
echo '
';
foreach ( $nodes as $node ) {
if ( ! is_array( $node ) ) {
continue;
}
$members = is_array( $node['members'] ?? null ) ? $node['members'] : array();
$status = is_scalar( $node['id'] ?? null ) && is_array( $plan[ (string) $node['id'] ] ?? null ) ? self::status_label( $plan[ (string) $node['id'] ] ) : '';
echo '' . esc_html( is_scalar( $node['name'] ?? null ) ? (string) $node['name'] : '' ) . ' ';
/* translators: %d: number of articles. */
echo esc_html( sprintf( _n( '(%d article)', '(%d articles)', count( $members ), 'creator-assistant-categories' ), count( $members ) ) );
if ( '' !== $status ) {
echo ' – ' . esc_html( $status ) . '';
}
echo '
';
// The titles the model saw when it named the node; a state saved
// before sample_ids existed only has the core points.
$samples = is_array( $node['sample_ids'] ?? null ) ? $node['sample_ids'] : ( is_array( $node['core_points'] ?? null ) ? $node['core_points'] : array() );
foreach ( $samples as $post_id ) {
if ( is_numeric( $post_id ) ) {
echo '- ' . esc_html( get_the_title( (int) $post_id ) ) . '
';
}
}
echo '
';
if ( is_array( $node['children'] ?? null ) && array() !== $node['children'] ) {
self::render_tree( $node['children'], $plan );
}
echo ' ';
}
echo '
';
}
/**
* What happens to a node's category, in words for a non-technical owner.
*
* @param array $entry Category_Planner::plan()['nodes'] entry: status, from and adopted.
*
* @return string Empty for an unknown status.
*/
private static function status_label( array $entry ): string {
$status = $entry['status'] ?? '';
$from = is_scalar( $entry['from'] ?? null ) ? (string) $entry['from'] : '';
$adopted = ! empty( $entry['adopted'] );
if ( 'kept' === $status ) {
return $adopted
? __( 'adopted: a category you made, now a topic category, stays where it is', 'creator-assistant-categories' )
: __( 'existing category, stays where it is', 'creator-assistant-categories' );
}
if ( 'moved' === $status ) {
$label = $adopted
? __( 'adopted: a category you made, now a topic category, moved', 'creator-assistant-categories' )
: __( 'existing category, moved', 'creator-assistant-categories' );
return '' === $from
/* translators: %s: what happens to the category, e.g. "existing category, moved". */
? sprintf( __( '%s: it was a main category', 'creator-assistant-categories' ), $label )
/* translators: 1: what happens to the category, e.g. "existing category, moved". 2: name of the category it was under. */
: sprintf( __( '%1$s: it was under ā%2$sā', 'creator-assistant-categories' ), $label, $from );
}
return 'new' === $status ? __( 'new category', 'creator-assistant-categories' ) : '';
}
/**
* Handler of the "Analyze" form.
*
* @return void
*/
public static function handle_analyze(): void {
self::check_request( self::ANALYZE_ACTION );
$input = array(
'thresholds' => isset( $_POST['thresholds'] ) && is_array( $_POST['thresholds'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['thresholds'] ) ) : array(), // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in check_request().
'min_cluster_size' => isset( $_POST['min_cluster_size'] ) ? sanitize_text_field( wp_unslash( $_POST['min_cluster_size'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in check_request().
);
self::redirect( self::handle_analyze_request( $input ) );
}
/**
* Saves the settings and starts an analysis. Separate from the handler so
* tests can call it: the handler ends with exit.
*
* @param array $input Raw form values.
*
* @return string Notice code for the page.
*/
public static function handle_analyze_request( array $input ): string {
$settings = Settings::sanitize( $input );
if ( $settings instanceof WP_Error ) {
return (string) $settings->get_error_code();
}
Settings::save( $settings );
$started = Analysis_Starter::start();
return $started instanceof WP_Error ? (string) $started->get_error_code() : 'analysis_started';
}
/**
* Handler of the "Apply categories" form.
*
* @return void
*/
public static function handle_apply(): void {
self::check_request( self::APPLY_ACTION );
self::redirect( self::handle_apply_request() );
}
/**
* Applies the ready preview. Separate from the handler for the same
* reason as handle_analyze_request().
*
* @return string Notice code for the page.
*/
public static function handle_apply_request(): string {
$result = Category_Applier::apply();
return $result instanceof WP_Error ? (string) $result->get_error_code() : 'applied';
}
/**
* Nonce and capability, before any change.
*
* @param string $action The admin-post action, also the nonce action.
*
* @return void
*/
private static function check_request( string $action ): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'creator-assistant-categories' ), '', array( 'response' => 403 ) );
}
check_admin_referer( $action );
}
/**
* Back to the page with a notice code.
*
* @param string $notice Notice code.
*
* @return void
*/
private static function redirect( string $notice ): void {
wp_safe_redirect(
add_query_arg(
array(
'page' => self::PAGE,
self::NOTICE_ARG => $notice,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* The text of a notice or error code. Covers every code the plugin
* produces: WP_Error codes of Settings, Analysis_Starter and
* Category_Applier, and the failures recorded by Run_State::fail().
*
* @param string $code Code.
*
* @return string Empty for an unknown code.
*/
public static function message( string $code ): string {
$messages = array(
'analysis_started' => __( 'Analysis started. It usually takes a few minutes: reload this page to see the result.', 'creator-assistant-categories' ),
'applied' => __( 'Categories applied.', 'creator-assistant-categories' ),
'invalid_thresholds' => __( 'Check the thresholds: three numbers between 0 and 1, each higher than the one before.', 'creator-assistant-categories' ),
'invalid_min_cluster_size' => __( 'Check the minimum number of articles per category.', 'creator-assistant-categories' ),
'service_inactive' => __( 'Creator Assistant Hub is not connected yet: complete its settings first.', 'creator-assistant-categories' ),
'analysis_running' => __( 'An analysis is already running.', 'creator-assistant-categories' ),
'no_post_types' => __( 'No post type with categories is summarized by Creator Assistant Hub.', 'creator-assistant-categories' ),
'not_enough_summaries' => __( 'Not enough articles are summarized yet. Try again after Creator Assistant Hub has processed them.', 'creator-assistant-categories' ),
'trigger_failed' => __( 'The analysis service did not accept the request. Try again later.', 'creator-assistant-categories' ),
'no_preview' => __( 'There is no preview to apply. Run an analysis first.', 'creator-assistant-categories' ),
'apply_failed' => __( 'The categories could not be applied. Try again.', 'creator-assistant-categories' ),
'timeout' => __( 'The analysis took more than a day and was stopped. Start a new one.', 'creator-assistant-categories' ),
'task_stopped' => __( 'The analysis service stopped before sending a result.', 'creator-assistant-categories' ),
'task_missing' => __( 'The analysis service lost track of the analysis.', 'creator-assistant-categories' ),
'empty_tree' => __( 'The analysis returned no usable group of articles.', 'creator-assistant-categories' ),
'naming_failed' => __( 'The categories could not be named. Try the analysis again.', 'creator-assistant-categories' ),
'trigger_lost' => __( 'The analysis could not be started. Start it again.', 'creator-assistant-categories' ),
'naming_timeout' => __( 'The categories were not named in time. Start the analysis again.', 'creator-assistant-categories' ),
'apply_busy' => __( 'The categories are already being applied.', 'creator-assistant-categories' ),
'summaries_unavailable' => __( 'The article summaries could not be read from Qdrant. Check the Creator Assistant Hub settings and try again.', 'creator-assistant-categories' ),
);
return $messages[ $code ] ?? '';
}
/**
* The translated text of a failure's detail code (error_detail['code']),
* which says why a failure happened when its error code alone does not.
*
* @param string $code Detail code, e.g. Tree_Namer::REQUEST_FAILED.
*
* @return string Empty for an unknown code: the view then shows the
* English description stored with it.
*/
public static function detail_message( string $code ): string {
$messages = array(
Tree_Namer::REQUEST_FAILED => __( 'OpenAI did not answer the naming request. It may be temporarily unavailable: try again later.', 'creator-assistant-categories' ),
Tree_Namer::INVALID_NAMES => __( 'The AI model gave incomplete or repeated names twice. Running the analysis again usually solves it.', 'creator-assistant-categories' ),
);
return $messages[ $code ] ?? '';
}
}