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 ''; } /** * 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 ] ?? ''; } }