array('label' => __('Suggestions', 'ai-eshop-optimizer'), 'icon' => 'lightbulb'), 'anchors' => array('label' => __('Anchors', 'ai-eshop-optimizer'), 'icon' => 'pin'), 'related' => array('label' => __('Related', 'ai-eshop-optimizer'), 'icon' => 'link'), 'clusters' => array('label' => __('Clusters', 'ai-eshop-optimizer'), 'icon' => 'layers'), 'gaps' => array('label' => __('Gaps', 'ai-eshop-optimizer'), 'icon' => 'search'), 'graph' => array('label' => __('Graph', 'ai-eshop-optimizer'), 'icon' => 'workflow'), 'diagnostics' => array('label' => __('Diagnostics', 'ai-eshop-optimizer'), 'icon' => 'activity'), 'settings' => array('label' => __('Settings', 'ai-eshop-optimizer'), 'icon' => 'settings'), ); } public function get_pro_promo() { return array( 'title' => __('Semantic Internal Linking', 'ai-eshop-optimizer'), 'description' => __('A Greek-aware semantic engine that reads your whole catalogue and content, understands inflection, accents and greeklish, and turns that understanding into internal links, related content on any content type, and topic clusters. Nothing is ever auto-published: you decide what the engine is allowed to say, who it is allowed to say it to, and how old a page may be before it stops being recommended.', 'ai-eshop-optimizer'), 'bullets' => array( __('Anchor suggestions that match Greek inflections, so that one term covers all of its forms including greeklish', 'ai-eshop-optimizer'), __('Related products and content with visible provenance: embedding, lexical or cluster, never a black box', 'ai-eshop-optimizer'), __('Audience policy: a grid of who each category, tag and brand is for, so a related section never recommends across audiences', 'ai-eshop-optimizer'), __('Publication window: stop recommending content past a chosen age, with an exemption for the pages that never go stale', 'ai-eshop-optimizer'), __('Choose the language model the engine runs on, and change your mind without losing what it has learned', 'ai-eshop-optimizer'), __('Topic clusters that reveal what your site owns and where competitors out-cover you', 'ai-eshop-optimizer'), __('Approve, edit or reject every link; rejections are remembered forever across recomputes', 'ai-eshop-optimizer'), ), ); } public function register_hooks() { add_action('wp_ajax_aieo_dmm_semantic_diag_analyze', array($this, 'ajax_diag_analyze')); add_action('wp_ajax_aieo_dmm_semantic_save_settings', array($this, 'ajax_save_settings')); add_action('wp_ajax_aieo_dmm_semantic_reindex', array($this, 'ajax_reindex')); add_action('wp_ajax_aieo_dmm_semantic_audience_scan', array($this, 'ajax_audience_scan')); add_action('wp_ajax_aieo_dmm_semantic_model_save', array($this, 'ajax_model_save')); add_action('wp_ajax_aieo_dmm_semantic_models', array($this, 'ajax_models_load')); add_action('wp_ajax_aieo_dmm_semantic_decide', array($this, 'ajax_decide')); add_action('wp_ajax_aieo_dmm_semantic_bulk_decide', array($this, 'ajax_bulk_decide')); add_action('wp_ajax_aieo_dmm_semantic_related_curate', array($this, 'ajax_related_curate')); add_action('wp_ajax_aieo_dmm_semantic_backfill_start', array($this, 'ajax_backfill_start')); add_action('wp_ajax_aieo_dmm_semantic_backfill_step', array($this, 'ajax_backfill_step')); add_action('wp_ajax_aieo_dmm_semantic_backfill_status', array($this, 'ajax_backfill_status')); add_action('wp_ajax_aieo_dmm_semantic_graph_search', array($this, 'ajax_graph_search')); add_action('wp_ajax_aieo_dmm_semantic_group_rows', array($this, 'ajax_group_rows')); add_action('wp_ajax_aieo_dmm_semantic_experiment', array($this, 'ajax_experiment')); add_action('wp_ajax_aieo_dmm_semantic_object_search', array($this, 'ajax_object_search')); add_action('wp_ajax_aieo_dmm_semantic_cluster_graph', array($this, 'ajax_cluster_graph')); add_action('wp_ajax_aieo_dmm_semantic_mwe_decide', array($this, 'ajax_mwe_decide')); add_action('wp_ajax_aieo_dmm_semantic_pair_scan', array($this, 'ajax_pair_scan')); add_filter('aieo_report_rows', array($this, 'decorate_report_rows'), 10, 2); add_filter('aieo_report_save_cell', array($this, 'save_report_cell'), 10, 3); add_filter('aieo_report_bulk', array($this, 'report_bulk'), 10, 3); add_action('wp_ajax_aieo_dmm_semantic_rule_save', array($this, 'ajax_rule_save')); add_action('wp_ajax_aieo_dmm_semantic_rule_rows', array($this, 'ajax_rule_rows')); add_action('wp_ajax_aieo_dmm_semantic_rule_delete', array($this, 'ajax_rule_delete')); add_action('wp_ajax_aieo_dmm_semantic_rule_toggle', array($this, 'ajax_rule_toggle')); add_action('wp_ajax_aieo_dmm_semantic_target_search', array($this, 'ajax_target_search')); add_action('wp_ajax_aieo_dmm_semantic_ilj_scan', array($this, 'ajax_ilj_scan')); add_action('wp_ajax_aieo_dmm_semantic_ilj_import', array($this, 'ajax_ilj_import')); add_action('wp_ajax_aieo_dmm_semantic_rule_usage', array($this, 'ajax_rule_usage')); add_action('wp_ajax_aieo_dmm_semantic_rule_map', array($this, 'ajax_rule_map')); add_filter('aieo_dmm_admin_navigator_cards', array($this, 'navigator_cards')); add_filter('aieo_capabilities', array($this, 'declare_capabilities')); } public function navigator_cards( $cards ) { $slug = AIEO_DMM::MENU_SLUG; $add = array( array('label' => __('Anchor rules (always link this word here)', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'anchors'), 'keywords' => 'anchor rule manual link glossary tooltip keyword αγκυρα κανονας γλωσσαρι'), array('label' => __('Import links from Internal Link Juicer', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'anchors'), 'anchor' => 'aieo-card-semlink-ilj', 'keywords' => 'ilj internal link juicer import migrate keywords εισαγωγη'), array('label' => __('Content gaps', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'gaps')), array('label' => __('Related content', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'related')), array('label' => __('Topic clusters', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'clusters')), array('label' => __('Semantic graph', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'graph')), array('label' => __('Semantic diagnostics', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'diagnostics')), array('label' => __('Vocabulary discovery (multi-word terms)', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'diagnostics'), 'anchor' => 'aieo-card-semlink-mwe'), array('label' => __('Recommendation A/B test', 'ai-eshop-optimizer'), 'page_slug' => $slug, 'dmm_tab' => 'seo_linking', 'query' => array('sem_section' => 'diagnostics'), 'anchor' => 'aieo-card-semlink-experiment'), array('label' => __('AI meta descriptions & keywords', 'ai-eshop-optimizer'), 'url' => admin_url('edit.php?post_type=product'), 'keywords' => 'meta description keywords ai suggest schema περιγραφή λέξεις κλειδιά'), ); return array_merge( (array) $cards, $add ); } public function declare_capabilities( $rows ) { $base = add_query_arg( array('page' => AIEO_DMM::MENU_SLUG, 'dmm_tab' => 'seo_linking', 'sem_section' => 'diagnostics'), admin_url('admin.php') ); $rows['tool:seo_linking:mwe'] = array( 'id' => 'tool:seo_linking:mwe', 'label' => __('Vocabulary discovery (multi-word terms)', 'ai-eshop-optimizer'), 'kind' => 'tool', 'page' => 'seo_linking', 'group' => 'seo', 'url' => $base . '#aieo-card-semlink-mwe', 'description' => __('Approve or reject the multi-word terms the engine discovers in your catalogue', 'ai-eshop-optimizer'), 'keywords' => 'mwe multi-word terms vocabulary discovery entities keywords λεξιλόγιο όροι', ); $rows['tool:seo_linking:ab'] = array( 'id' => 'tool:seo_linking:ab', 'label' => __('Recommendation A/B test', 'ai-eshop-optimizer'), 'kind' => 'tool', 'page' => 'seo_linking', 'group' => 'seo', 'url' => $base . '#aieo-card-semlink-experiment', 'description' => __('Split traffic to measure whether the semantic recommendation changes actually help', 'ai-eshop-optimizer'), 'keywords' => 'ab test experiment split traffic recommendations diversity πείραμα', ); $anchors = add_query_arg( array('page' => AIEO_DMM::MENU_SLUG, 'dmm_tab' => 'seo_linking', 'sem_section' => 'anchors'), admin_url('admin.php') ); $rows['tool:seo_linking:ilj_import'] = array( 'id' => 'tool:seo_linking:ilj_import', 'label' => __('Import links from Internal Link Juicer', 'ai-eshop-optimizer'), 'kind' => 'tool', 'page' => 'seo_linking', 'group' => 'seo', 'url' => $anchors . '#aieo-card-semlink-ilj', 'description' => __('Read the keywords Internal Link Juicer stores on each target and turn them into anchor rules, so ILJ can be switched off', 'ai-eshop-optimizer'), 'keywords' => 'ilj internal link juicer import migrate keywords linkdefinition εισαγωγή σύνδεσμοι', ); $rows['tool:seo:ai_meta'] = array( 'id' => 'tool:seo:ai_meta', 'label' => __('AI meta descriptions & keywords', 'ai-eshop-optimizer'), 'kind' => 'tool', 'page' => 'seo_linking', 'group' => 'seo', 'url' => admin_url('edit.php?post_type=product'), 'description' => __('In any product/post/page editor: keywords and a meta description suggested from your index, Search Console and AI', 'ai-eshop-optimizer'), 'keywords' => 'meta description keywords ai suggest seo title metabox schema λέξεις κλειδιά περιγραφή', ); return $rows; } public function ajax_experiment() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Reco_Experiment')) { wp_send_json_error(array('message' => __('Experiment support is not available in this build.', 'ai-eshop-optimizer'))); } $do = isset($_POST['do']) ? sanitize_key(wp_unslash($_POST['do'])) : ''; if ('start' === $do) { $split = isset($_POST['split']) ? (int) $_POST['split'] : 50; $subject = isset($_POST['subject']) ? sanitize_key(wp_unslash($_POST['subject'])) : ''; $subject = in_array($subject, AIEO_Reco_Experiment::SUBJECTS, true) ? $subject : 'semantic_reco_boost'; $config = AIEO_Reco_Experiment::start($subject, $split); $message = (AIEO_Reco_Experiment::SUBJECT_DIVERSITY === $subject) ? __('Diversity experiment started. New sessions are split three ways on the related rail — brand cap 2 (today\'s default), no cap, and co-visitation blended in. Leave it running; a few days is rarely enough.', 'ai-eshop-optimizer') : __('Experiment started. Half of new sessions now see the recommendations they would get with the semantic engine switched off — leave it running; a few days is rarely enough.', 'ai-eshop-optimizer'); wp_send_json_success(array( 'config' => $config, 'message' => $message, )); } if ('stop' === $do) { $config = AIEO_Reco_Experiment::stop(); wp_send_json_success(array( 'config' => $config, 'message' => __('Experiment stopped. Every visitor now gets the feature as configured; the results already collected are kept.', 'ai-eshop-optimizer'), )); } wp_send_json_error(array('message' => __('Unknown experiment action.', 'ai-eshop-optimizer'))); } const GROUP_ROWS_LIMIT = 100; const SEARCH_OBJECT_CAP = 500; private function engine_present() { return class_exists('AIEO_Semantic_Lang_El'); } private function index_ready() { static $ready = null; if (null !== $ready) { return $ready; } global $wpdb; $t = $wpdb->prefix . 'aieo_sem_objects'; if ((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $t)) !== $t) { return $ready = false; } return $ready = (bool) $wpdb->get_var("SELECT 1 FROM {$t} WHERE dirty = 0 LIMIT 1"); } public function enqueue_admin_assets() { wp_enqueue_style('aieo-seo-admin', AIEO_URI . 'assets/css/aieo-seo-admin.min.css', array(), AIEO_VERSION); } public function render_subtab() { $tab = isset($_GET['sem_section']) ? sanitize_key(wp_unslash($_GET['sem_section'])) : 'suggestions'; $tabs = array_keys($this->get_tabs()); if (!in_array($tab, $tabs, true)) { $tab = 'suggestions'; } $engine = $this->engine_present(); if (!$engine) { echo '

' . esc_html__('Rooster One Pro is not installed.', 'ai-eshop-optimizer') . ' ' . esc_html__('The Greek semantic engine (indexer, stemmer, phonetic matcher, link generator) ships with the Rooster One Pro plugin. Install and activate it to index this catalog and generate suggestions — this screen then comes alive without further setup.', 'ai-eshop-optimizer') . '

'; if (class_exists('AIEO_One_Installer')) { AIEO_One_Installer::render('seo_linking'); } } if (class_exists('AIEO_DMM_UI')) { AIEO_DMM_UI::tab_strip($this->get_id(), $this->get_tabs(), $tab, array( 'arg' => $this->get_tab_arg(), 'base' => remove_query_arg('sem_section'), 'wrapper' => 'div', 'margin' => '14px', )); } $index_ready = $engine ? $this->index_ready() : false; $vars = array( 'nonce' => wp_create_nonce(AIEO_DMM::NONCE_ACTION), 'ajax' => admin_url('admin-ajax.php'), 'engine_present' => $engine, 'index_ready' => $index_ready, 'settings_url' => add_query_arg('sem_section', 'settings', remove_query_arg('sem_section')), ); switch ($tab) { case 'diagnostics': $vars['index_health'] = $this->index_health(); $vars['search_signal'] = class_exists('AIEO_Search_Signal') ? AIEO_Search_Signal::info() : array(); $vars['reco'] = $this->reco_diagnostics(); $vars['experiment'] = class_exists('AIEO_Reco_Experiment_Report') ? AIEO_Reco_Experiment_Report::build() : array(); $vars['mwe'] = $this->mwe_queue(); break; case 'settings': $vars['settings'] = $this->current_settings(); $vars['sync_readiness'] = $this->sync_readiness(); $vars['post_type_choices'] = $this->selectable_post_types(); $vars['auto_post_type_choices'] = $this->auto_placement_post_types(); $vars['backfill'] = class_exists('AIEO_Semantic_Backfill') ? array_merge(AIEO_Semantic_Backfill::preview(), AIEO_Semantic_Backfill::runner_status()) : array('ready' => false); if (!class_exists('AIEO_Semantic_Transport') || !method_exists('AIEO_Semantic_Transport', 'models')) { $vars['models'] = new WP_Error('om_sem_no_transport', __('The semantic transport is not available on this install.', 'ai-eshop-optimizer')); } else { $aieo_cached_models = get_transient(AIEO_Semantic_Transport::TR_MODELS); $vars['models'] = is_array($aieo_cached_models) ? $aieo_cached_models : null; $vars['models_unloaded'] = !is_array($aieo_cached_models) && '' !== (string) get_option('aieo_rooster_api_key', ''); } break; case 'suggestions': $state = $this->view_state(); $vars['view'] = $state; $vars['search_capped'] = $index_ready ? $this->search_truncated() : false; $vars['groups'] = ($index_ready && 'none' !== $state['group']) ? $this->read_link_groups(50) : array(); $vars['rows'] = array(); $vars['status_counts'] = $index_ready ? $this->link_status_counts() : array(); $vars['total'] = $index_ready ? $this->surface_total('links') : 0; $vars['per_page'] = 50; $vars['page'] = $state['page']; break; case 'anchors': $r_origin = isset($_GET['sem_origin']) ? sanitize_key(wp_unslash($_GET['sem_origin'])) : ''; $r_q = isset($_GET['sem_q']) ? sanitize_text_field(wp_unslash($_GET['sem_q'])) : ''; $r_page = isset($_GET['sem_page']) ? max(1, (int) $_GET['sem_page']) : 1; $r_present = isset($_GET['sem_present']) ? sanitize_text_field(wp_unslash($_GET['sem_present'])) : ''; $r_orderby = isset($_GET['sem_orderby']) ? sanitize_key(wp_unslash($_GET['sem_orderby'])) : ''; $r_order = isset($_GET['sem_order']) ? sanitize_key(wp_unslash($_GET['sem_order'])) : 'asc'; $vars['rule_present'] = $r_present; $vars['rule_orderby'] = $r_orderby; $vars['rule_order'] = ('desc' === $r_order) ? 'desc' : 'asc'; $vars['rules'] = array('rows' => array(), 'total' => 0); $vars['rule_stats'] = array('total' => 0, 'enabled' => 0, 'manual' => 0, 'ilj' => 0, 'capped' => false); $vars['rule_origin'] = $r_origin; $vars['rule_q'] = $r_q; $r_per = isset($_GET['sem_per_page']) ? (int) $_GET['sem_per_page'] : 50; $vars['per_page'] = in_array($r_per, array(25, 50, 100), true) ? $r_per : 50; $vars['page'] = $r_page; $vars['ilj_targets'] = 0; $vars['present_options'] = $this->present_options(); $vars['target_types'] = $this->target_type_labels(); $vars['languages'] = $this->rule_language_choices(); if ($engine && class_exists('AIEO_Semantic_Link_Rules')) { $vars['rules'] = AIEO_Semantic_Link_Rules::page(array( 'origin' => $r_origin, 'q' => $r_q, 'present' => $r_present, 'orderby' => $r_orderby, 'order' => $vars['rule_order'], 'page' => $r_page, 'per_page' => $vars['per_page'], )); $vars['rule_stats'] = AIEO_Semantic_Link_Rules::stats(); $vars['max_rules'] = AIEO_Semantic_Link_Rules::MAX_RULES; $vars['hits_stats'] = AIEO_Semantic_Link_Rules::hits_stats(); } if ($engine && class_exists('AIEO_Semantic_Ilj_Import')) { $vars['ilj_targets'] = AIEO_Semantic_Ilj_Import::count_targets(); } break; case 'related': $state = $this->view_state(); $vars['view'] = $state; $vars['search_capped'] = $index_ready ? $this->search_truncated() : false; $vars['groups'] = ($index_ready && 'none' !== $state['group']) ? $this->read_related_groups(50) : array(); $vars['rows'] = ($index_ready && 'none' === $state['group']) ? $this->read_related(50) : array(); $vars['total'] = $index_ready ? $this->surface_total('related') : 0; $vars['per_page'] = 50; $vars['page'] = $state['page']; break; case 'clusters': $state = $this->view_state(); $vars['view'] = $state; $vars['rows'] = $index_ready ? $this->read_clusters(50) : array(); $vars['total_rows'] = $index_ready ? $this->surface_total('clusters') : 0; $vars['per_page'] = 50; $vars['page'] = $state['page']; break; case 'graph': $vars['graph_object'] = isset($_GET['sem_obj']) ? (int) $_GET['sem_obj'] : 0; $vars['graph_choices'] = $engine ? $this->graph_choices(100) : array(); break; case 'gaps': $g_window = isset($_GET['sem_window']) ? max(1, min(365, (int) $_GET['sem_window'])) : 30; $g_floor = isset($_GET['sem_floor']) ? max(1, (int) $_GET['sem_floor']) : 3; $g_page = isset($_GET['sem_page']) ? max(1, (int) $_GET['sem_page']) : 1; $vars['gaps'] = array('rows' => array(), 'total' => 0, 'meta' => array( 'window_days' => $g_window, 'min_searches' => $g_floor, 'gsc_active' => false, 'capped' => false, 'unenriched' => 0, )); if ($engine && class_exists('AIEO_Semantic_Content_Gaps')) { AIEO_Semantic_Content_Gaps::enrich(800); $vars['gaps'] = AIEO_Semantic_Content_Gaps::gaps(array( 'window_days' => $g_window, 'min_searches' => $g_floor, 'per_page' => 50, 'page' => $g_page, )); } $vars['per_page'] = 50; $vars['page'] = $g_page; break; } $aieo_band = function_exists('rooster_admin_band_open'); if ($aieo_band) { rooster_admin_band_open(in_array($tab, array('anchors', 'diagnostics'), true) ? array('cols' => 2) : array()); } try { $this->render_template('seo-linking/' . $tab . '.php', $vars); } finally { if ($aieo_band) { rooster_admin_band_close(); } } } public static function tab_state($kind, array $o = array()) { $text = (string) ($o['text'] ?? ''); if ('no-engine' === $kind) { return \Rooster\Admin\States::state('not-set-up', array( 'title' => __('Rooster One Pro is not installed.', 'ai-eshop-optimizer'), 'text' => $text, )); } $url = (string) ($o['url'] ?? ''); $label = (string) ($o['label'] ?? html_entity_decode(__('Settings → Index the site', 'ai-eshop-optimizer'), ENT_QUOTES, 'UTF-8')); return \Rooster\Admin\States::state('not-run', array( 'title' => __('This catalogue has not been indexed yet.', 'ai-eshop-optimizer'), 'text' => $text, 'action' => '' !== $url ? \Rooster\Admin\Ui::button($label, array('href' => $url)) : '', )); } private function current_settings() { $defaults = array( 'enabled' => false, 'link_injection_enabled' => false, 'link_suggestion_mode' => 'manual', 'sync_enabled' => false, 'max_links_per_paragraph' => 3, 'max_links_per_page' => 20, 'reco_signal_enabled' => false, 'reco_boost_alpha' => 0.15, 'staleness_days' => 14, 'top_terms_per_object' => 150, 'related_min_score' => 0, 'related_min_relative' => 0, 'related_two_hop_enabled' => false, 'related_two_hop_max_bridge' => 40, 'related_block_auto' => false, 'related_auto_post_types' => class_exists('AIEO_Semantic_Related_Display') ? AIEO_Semantic_Related_Display::DEFAULT_AUTO_POST_TYPES : array('post'), 'related_block_limit' => 6, 'related_block_title' => '', 'related_max_per_brand' => 2, 'related_max_per_category' => 0, 'audience_mode' => 'off', 'audience_title_markers' => true, 'audience_terms' => array(), 'audience_grid' => array(), 'related_time_mode' => 'off', 'related_time_months' => class_exists('AIEO_Semantic_Temporality') ? AIEO_Semantic_Temporality::DEFAULT_MONTHS : 6, 'related_time_post_types' => array(), 'related_time_evergreen_types' => array(), 'related_never_empty' => true, 'lang' => 'auto', 'languages' => array(), 'lang_rules' => array(), 'extra_post_types' => array(), 'tooltip_post_types' => array('tooltip'), 'tooltip_style' => array(), ); if (!class_exists('AIEO_DMM_Settings')) { return $defaults; } $out = array(); foreach ($defaults as $key => $default) { $out[$key] = AIEO_DMM_Settings::get('semantic.' . $key, $default); } if (class_exists('AIEO_Semantic_Transport')) { $out['sync_enabled'] = AIEO_Semantic_Transport::sync_enabled(); } return $out; } private function sync_readiness() { if (!class_exists('AIEO_Semantic_Transport')) { return array('ready' => false, 'reason' => 'engine'); } if ('' === (string) AIEO_Semantic_Transport::api_key()) { return array('ready' => false, 'reason' => 'no_api_key'); } return array('ready' => true, 'reason' => ''); } private function table_exists($suffix) { global $wpdb; $table = $wpdb->prefix . $suffix; return (string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table; } private function view_state() { static $state = null; if (null !== $state) { return $state; } $src = isset($_GET['sem_src']) ? sanitize_text_field(wp_unslash($_GET['sem_src'])) : ''; $parts = ('' !== $src) ? explode(':', $src, 2) : array(); $state = $this->normalize_state(array( 'status' => isset($_GET['sem_status']) ? sanitize_key(wp_unslash($_GET['sem_status'])) : 'suggested', 'orderby' => isset($_GET['sem_orderby']) ? sanitize_key(wp_unslash($_GET['sem_orderby'])) : '', 'order' => isset($_GET['sem_order']) ? sanitize_key(wp_unslash($_GET['sem_order'])) : '', 'page' => isset($_GET['sem_page']) ? (int) $_GET['sem_page'] : 1, 'q' => isset($_GET['sem_q']) ? sanitize_text_field(wp_unslash($_GET['sem_q'])) : '', 'group' => isset($_GET['sem_group']) ? sanitize_key(wp_unslash($_GET['sem_group'])) : 'none', 'src_type' => isset($parts[0]) ? sanitize_key($parts[0]) : '', 'src_id' => isset($parts[1]) ? (int) $parts[1] : 0, 'anchor' => isset($_GET['sem_anchor']) ? sanitize_text_field(wp_unslash($_GET['sem_anchor'])) : '', )); return $state; } private function post_state() { return $this->normalize_state(array( 'status' => isset($_POST['status']) ? sanitize_key(wp_unslash($_POST['status'])) : 'suggested', 'q' => isset($_POST['q']) ? sanitize_text_field(wp_unslash($_POST['q'])) : '', 'group' => isset($_POST['group_by']) ? sanitize_key(wp_unslash($_POST['group_by'])) : 'none', 'src_type' => isset($_POST['src_type']) ? sanitize_key(wp_unslash($_POST['src_type'])) : '', 'src_id' => isset($_POST['src_id']) ? (int) $_POST['src_id'] : 0, 'anchor' => isset($_POST['anchor']) ? sanitize_text_field(wp_unslash($_POST['anchor'])) : '', )); } private function normalize_state(array $in) { $status = isset($in['status']) ? (string) $in['status'] : 'suggested'; if (!in_array($status, array('suggested', 'approved', 'rejected', 'all'), true)) { $status = 'suggested'; } $group = isset($in['group']) ? (string) $in['group'] : 'none'; if (!in_array($group, array('none', 'source', 'anchor'), true)) { $group = 'none'; } $orderby = isset($in['orderby']) ? (string) $in['orderby'] : ''; if (!in_array($orderby, array( 'score', 'status', 'anchor', 'computed', 'count', 'cluster', 'size', 'share', 'dominance', ), true)) { $orderby = ('none' === $group) ? 'score' : 'count'; } return array( 'status' => $status, 'orderby' => $orderby, 'order' => (isset($in['order']) && 'asc' === strtolower((string) $in['order'])) ? 'asc' : 'desc', 'page' => max(1, isset($in['page']) ? (int) $in['page'] : 1), 'q' => trim(mb_substr(isset($in['q']) ? (string) $in['q'] : '', 0, 100)), 'group' => $group, 'src_type' => isset($in['src_type']) ? (string) $in['src_type'] : '', 'src_id' => max(0, isset($in['src_id']) ? (int) $in['src_id'] : 0), 'anchor' => trim(mb_substr(isset($in['anchor']) ? (string) $in['anchor'] : '', 0, 191)), ); } private function search_object_ids($q) { static $memo = array(); if (isset($memo[$q])) { return $memo[$q]; } global $wpdb; $rows = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_title LIKE %s ORDER BY ID LIMIT %d", '%' . $wpdb->esc_like($q) . '%', self::SEARCH_OBJECT_CAP + 1 )); $ids = array_map('intval', (array) $rows); $truncated = (count($ids) > self::SEARCH_OBJECT_CAP); if ($truncated) { array_pop($ids); } return $memo[$q] = array('ids' => $ids, 'truncated' => $truncated); } private function search_truncated() { $state = $this->view_state(); if ('' === $state['q']) { return false; } $hit = $this->search_object_ids($state['q']); return !empty($hit['truncated']); } private function links_filter(array $state, $need_titles = true) { global $wpdb; $join = ''; $where = array(); $args = array(); if ('all' !== $state['status']) { $where[] = 'l.status = %s'; $args[] = $state['status']; } if ('' !== $state['src_type'] && $state['src_id'] > 0) { $where[] = 'l.source_type = %s AND l.source_id = %d'; $args[] = $state['src_type']; $args[] = $state['src_id']; } if ('' !== $state['anchor']) { $where[] = 'l.anchor_norm = %s'; $args[] = $state['anchor']; } if ('' !== $state['q'] && $need_titles) { $hit = $this->search_object_ids($state['q']); $clauses = array('l.anchor_text LIKE %s'); $args[] = '%' . $wpdb->esc_like($state['q']) . '%'; if ($hit['ids']) { $in = implode(',', $hit['ids']); $clauses[] = "l.source_id IN ({$in})"; $clauses[] = "l.target_id IN ({$in})"; } $where[] = '(' . implode(' OR ', $clauses) . ')'; } return array( 'join' => $join, 'where' => $where ? (' WHERE ' . implode(' AND ', $where)) : '', 'args' => $args, ); } public function report_sql(array $params) { global $wpdb; $state = array( 'status' => 'all', 'q' => isset($params['q']) ? (string) $params['q'] : '', 'src_type' => isset($params['src_type']) ? (string) $params['src_type'] : '', 'src_id' => isset($params['src_id']) ? (int) $params['src_id'] : 0, 'anchor' => isset($params['anchor']) ? (string) $params['anchor'] : '', ); $f = $this->links_filter($state); $cols = "l.id, l.source_type, l.source_id, l.target_type, l.target_id, l.anchor_text, l.anchor_norm, l.edited_anchor, l.match_type, l.score, l.status, l.model_version, l.computed_at, l.entity_tax, l.entity_term_id, l.present, '' AS source_label, '' AS source_href, '' AS source_note, '' AS target_label, '' AS target_href, '' AS target_note, '' AS shown_anchor"; $sql = "SELECT {$cols} FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']}"; return $f['args'] ? $wpdb->prepare($sql, $f['args']) : $sql; } public function decorate_report_rows($rows, $def) { if (!is_object($def) || 'seo_linking_suggestions' !== $def->id()) { return $rows; } $objs = array_map( static function ($r) { return (object) $r; }, $rows ); $objs = $this->decorate_objects($objs); foreach ($objs as $i => $o) { $row = (array) $o; $row['source_label'] = ('' !== (string) $o->source_title) ? (string) $o->source_title : ($o->source_type . ' #' . (int) $o->source_id); $row['source_href'] = isset($o->source_url) ? (string) $o->source_url : ''; $row['source_note'] = (string) $o->source_type; if (!empty($o->target_entity) && is_array($o->target_entity)) { $e = $o->target_entity; $row['target_label'] = (string) $e['name']; $row['target_href'] = (string) $e['url']; $row['target_note'] = sprintf( __('%1$s archive · %2$s items · via %3$s', 'ai-eshop-optimizer'), (string) $e['taxonomy'], number_format_i18n((int) $e['count']), ('' !== (string) $o->target_title) ? (string) $o->target_title : ('#' . (int) $o->target_id) ); } else { $row['target_label'] = ('' !== (string) $o->target_title) ? (string) $o->target_title : ($o->target_type . ' #' . (int) $o->target_id); $row['target_href'] = isset($o->target_url) ? (string) $o->target_url : ''; $row['target_note'] = (string) $o->target_type; } $edited = (string) $o->edited_anchor; $row['shown_anchor'] = ('' !== $edited) ? $edited : (string) $o->anchor_text; $rows[$i] = $row; } return $rows; } public function save_report_cell($handled, $ctx, $def) { if (true === $handled || 'seo_linking_suggestions' !== (string) $ctx['report']) { return $handled; } if ('present' !== (string) $ctx['column']) { return $handled; } $this->set_present((int) $ctx['row_key'], (string) $ctx['value']); return true; } public function report_bulk($handled, $ctx, $def) { if (false !== $handled || 'seo_linking_suggestions' !== (string) $ctx['report']) { return $handled; } $map = array('approve' => 'approved', 'reject' => 'rejected', 'restore' => 'suggested'); if (!isset($map[(string) $ctx['action']])) { return $handled; } if (!self::is_aieo_pro_user()) { return __('This feature requires the Pro version.', 'ai-eshop-optimizer'); } if (!$this->table_exists('aieo_sem_links')) { return __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'); } $target = $map[(string) $ctx['action']]; $ids = array_values(array_unique(array_filter(array_map('intval', (array) $ctx['ids'])))); if (!$ids) { return array('updated' => 0, 'skipped' => 0); } global $wpdb; $ph = implode(',', array_fill(0, count($ids), '%d')); $rows = (array) $wpdb->get_results($wpdb->prepare( "SELECT id, source_type, source_id, target_type, target_id, match_type, status FROM {$wpdb->prefix}aieo_sem_links WHERE id IN ({$ph})", $ids )); $skipped = 0; if ('approved' === $target) { $keep = array(); foreach ($rows as $row) { if (in_array((string) $row->match_type, array('phonetic', 'embedding'), true)) { if ((string) $row->status !== $target) { $skipped++; } continue; } $keep[] = $row; } $rows = $keep; } $out = array( 'updated' => self::decide_rows($rows, $target, get_current_user_id()), 'skipped' => $skipped, ); if ($skipped > 0) { $out['message'] = __('Phonetic and embedding matches need per-row approval.', 'ai-eshop-optimizer'); } return $out; } public function set_present($id, $present) { global $wpdb; $id = (int) $id; $val = class_exists('AIEO_Semantic_Link_Rules') ? AIEO_Semantic_Link_Rules::normalize_present((string) $present) : ''; if ($id <= 0) { return $val; } $row = $wpdb->get_row($wpdb->prepare( "SELECT source_type, source_id FROM {$wpdb->prefix}aieo_sem_links WHERE id = %d", $id )); if (!$row) { return $val; } $wpdb->update($wpdb->prefix . 'aieo_sem_links', array('present' => $val), array('id' => $id)); self::bust_link_caches((string) $row->source_type, (int) $row->source_id); return $val; } private function links_order(array $state) { $allowed = array( 'score' => 'l.score', 'status' => 'l.status', 'anchor' => 'l.anchor_text', 'computed' => 'l.computed_at', 'count' => 'l.score', ); $col = isset($allowed[$state['orderby']]) ? $allowed[$state['orderby']] : 'l.score'; return $col . ' ' . ('asc' === $state['order'] ? 'ASC' : 'DESC'); } private function read_links($limit, ?array $state = null) { global $wpdb; if (!$this->table_exists('aieo_sem_links')) { return array(); } $state = (null === $state) ? $this->view_state() : $state; $f = $this->links_filter($state); $cols = "l.id, l.source_type, l.source_id, l.target_type, l.target_id, l.anchor_text, l.anchor_norm, l.edited_anchor, l.match_type, l.score, l.status, l.model_version, l.computed_at, l.entity_tax, l.entity_term_id, l.present"; $args = $f['args']; $args[] = (int) $limit; $args[] = ($state['page'] - 1) * (int) $limit; $sql = "SELECT {$cols} FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']} ORDER BY {$this->links_order($state)} LIMIT %d OFFSET %d"; return $this->decorate_objects((array) $wpdb->get_results($wpdb->prepare($sql, $args))); } private function read_link_groups($limit) { global $wpdb; if (!$this->table_exists('aieo_sem_links')) { return array(); } $state = $this->view_state(); $f = $this->links_filter($state); $anchor_mode = ('anchor' === $state['group']); if ($anchor_mode) { $select = "l.anchor_norm AS group_key, MIN(l.anchor_text) AS label, COUNT(*) AS n, COUNT(DISTINCT l.source_id) AS sources, MAX(l.score) AS top_score, MIN(l.score) AS low_score, MAX(l.entity_tax) AS entity_tax, MAX(l.entity_term_id) AS entity_term_id, SUM(l.status = 'suggested') AS n_pending, SUM(l.status = 'approved') AS n_approved, SUM(l.status = 'rejected') AS n_rejected"; $group = 'l.anchor_norm'; } else { $select = "CONCAT(l.source_type, ':', l.source_id) AS group_key, l.source_type, l.source_id, COUNT(*) AS n, COUNT(DISTINCT l.target_id) AS targets, MAX(l.score) AS top_score, MIN(l.score) AS low_score, COUNT(DISTINCT l.anchor_norm) AS anchors, -- Distinct DESTINATIONS, which is what the operator is -- actually deciding. An entity anchor routes to the -- archive, so many target products collapse to one link; -- showing only `n` made ten restatements of one decision -- look like ten decisions. COUNT(DISTINCT CASE WHEN l.entity_term_id > 0 THEN CONCAT('tax:', l.entity_tax, ':', l.entity_term_id) ELSE CONCAT('obj:', l.target_type, ':', l.target_id) END) AS destinations, SUM(l.status = 'suggested') AS n_pending, SUM(l.status = 'approved') AS n_approved, SUM(l.status = 'rejected') AS n_rejected"; $group = 'l.source_type, l.source_id'; } $order = ('score' === $state['orderby']) ? 'top_score' : 'n'; $dir = ('asc' === $state['order']) ? 'ASC' : 'DESC'; $args = $f['args']; $args[] = (int) $limit; $args[] = ($state['page'] - 1) * (int) $limit; $sql = "SELECT {$select} FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']} GROUP BY {$group} ORDER BY {$order} {$dir}, top_score DESC LIMIT %d OFFSET %d"; $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $args)); if (!$anchor_mode) { return $this->decorate_objects($rows); } foreach ($rows as $r) { $r->entity = null; $tax = (string) $r->entity_tax; $tid = (int) $r->entity_term_id; if ('' !== $tax && $tid > 0 && taxonomy_exists($tax)) { $term = get_term($tid, $tax); $link = ($term && !is_wp_error($term)) ? get_term_link($term) : ''; if ($term && !is_wp_error($term) && is_string($link)) { $r->entity = array( 'name' => (string) $term->name, 'url' => $link, 'taxonomy' => $tax, 'count' => (int) $term->count, ); } } } return $rows; } private function surface_total($surface) { global $wpdb; $state = $this->view_state(); if ('clusters' === $surface) { return $this->table_exists('aieo_sem_clusters') ? (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_clusters") : 0; } if ('related' === $surface) { if (!$this->table_exists('aieo_sem_related')) { return 0; } $f = $this->related_filter($state); if ('none' !== $state['group']) { $sql = "SELECT COUNT(*) FROM (SELECT 1 FROM {$wpdb->prefix}aieo_sem_related r{$f['join']}{$f['where']} GROUP BY r.source_type, r.source_id) g"; } else { $sql = "SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_related r{$f['join']}{$f['where']}"; } return (int) $wpdb->get_var($f['args'] ? $wpdb->prepare($sql, $f['args']) : $sql); } if (!$this->table_exists('aieo_sem_links')) { return 0; } $f = $this->links_filter($state); if ('anchor' === $state['group']) { $sql = "SELECT COUNT(DISTINCT l.anchor_norm) FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']}"; } elseif ('source' === $state['group']) { $sql = "SELECT COUNT(*) FROM (SELECT 1 FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']} GROUP BY l.source_type, l.source_id) g"; } else { $sql = "SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']}"; } return (int) $wpdb->get_var($f['args'] ? $wpdb->prepare($sql, $f['args']) : $sql); } private function link_status_counts() { global $wpdb; if (!$this->table_exists('aieo_sem_links')) { return array(); } $state = $this->view_state(); $state['status'] = 'all'; $f = $this->links_filter($state); $sql = "SELECT l.status, COUNT(*) n FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']} GROUP BY l.status"; $rows = (array) $wpdb->get_results($f['args'] ? $wpdb->prepare($sql, $f['args']) : $sql, ARRAY_A); $out = array('all' => 0); foreach ($rows as $r) { $out[(string) $r['status']] = (int) $r['n']; $out['all'] += (int) $r['n']; } return $out; } private function decorate_objects(array $rows) { if (empty($rows)) { return $rows; } $ids = array(); foreach ($rows as $r) { if (isset($r->source_id)) { $ids[] = (int) $r->source_id; } if (isset($r->target_id)) { $ids[] = (int) $r->target_id; } } $ids = array_values(array_unique(array_filter($ids))); if ($ids) { _prime_post_caches($ids, false, false); } foreach ($rows as $r) { $etax = isset($r->entity_tax) ? (string) $r->entity_tax : ''; $eid = isset($r->entity_term_id) ? (int) $r->entity_term_id : 0; $r->target_entity = null; if ('' !== $etax && $eid > 0 && taxonomy_exists($etax)) { $term = get_term($eid, $etax); $link = ($term && !is_wp_error($term)) ? get_term_link($term) : ''; if ($term && !is_wp_error($term) && is_string($link)) { $r->target_entity = array( 'name' => (string) $term->name, 'url' => $link, 'taxonomy' => $etax, 'count' => (int) $term->count, ); } } foreach (array('source', 'target') as $side) { $id_key = $side . '_id'; $type_key = $side . '_type'; $id = isset($r->$id_key) ? (int) $r->$id_key : 0; $type = isset($r->$type_key) ? (string) $r->$type_key : ''; $r->{$side . '_title'} = ''; $r->{$side . '_url'} = ''; $r->{$side . '_edit'} = ''; if ($id <= 0) { continue; } $post = get_post($id); if (!$post || ($type !== '' && $post->post_type !== $type)) { continue; } $r->{$side . '_title'} = (string) $post->post_title; $permalink = get_permalink($post); $r->{$side . '_url'} = $permalink ? (string) $permalink : ''; $edit = get_edit_post_link($id, 'raw'); $r->{$side . '_edit'} = $edit ? (string) $edit : ''; } } return $rows; } private function related_filter(array $state) { global $wpdb; $join = ''; $where = array(); $args = array(); if ('' !== $state['src_type'] && $state['src_id'] > 0) { $where[] = 'r.source_type = %s AND r.source_id = %d'; $args[] = $state['src_type']; $args[] = $state['src_id']; } if ('' !== $state['q']) { $hit = $this->search_object_ids($state['q']); if ($hit['ids']) { $in = implode(',', $hit['ids']); $where[] = "(r.source_id IN ({$in}) OR r.target_id IN ({$in}))"; } else { $where[] = '1 = 0'; } } return array( 'join' => $join, 'where' => $where ? (' WHERE ' . implode(' AND ', $where)) : '', 'args' => $args, ); } private function related_verdict_join() { global $wpdb; if (!$this->table_exists('aieo_sem_curation')) { return array("'' AS verdict", ''); } return array( "COALESCE(cur.verdict, '') AS verdict", "LEFT JOIN {$wpdb->prefix}aieo_sem_curation cur ON cur.surface = 'related' AND cur.source_type = r.source_type AND cur.source_id = r.source_id AND cur.target_type = r.target_type AND cur.target_id = r.target_id", ); } private function read_related($limit, ?array $state = null) { global $wpdb; if (!$this->table_exists('aieo_sem_related')) { return array(); } $state = (null === $state) ? $this->view_state() : $state; $f = $this->related_filter($state); list($verdict_col, $verdict_join) = $this->related_verdict_join(); $args = $f['args']; $args[] = (int) $limit; $args[] = ($state['page'] - 1) * (int) $limit; $sql = "SELECT r.source_type, r.source_id, r.`rank`, r.target_type, r.target_id, r.score, r.reason, r.model_version, r.computed_at, {$verdict_col} FROM {$wpdb->prefix}aieo_sem_related r{$f['join']} {$verdict_join}{$f['where']} ORDER BY r.source_type, r.source_id, r.`rank` LIMIT %d OFFSET %d"; return $this->decorate_objects((array) $wpdb->get_results($wpdb->prepare($sql, $args))); } private function read_related_groups($limit) { global $wpdb; if (!$this->table_exists('aieo_sem_related')) { return array(); } $state = $this->view_state(); $f = $this->related_filter($state); $order = ('score' === $state['orderby']) ? 'top_score' : 'n'; $dir = ('asc' === $state['order']) ? 'ASC' : 'DESC'; $args = $f['args']; $args[] = (int) $limit; $args[] = ($state['page'] - 1) * (int) $limit; $sql = "SELECT r.source_type, r.source_id, COUNT(*) AS n, MAX(r.score) AS top_score FROM {$wpdb->prefix}aieo_sem_related r{$f['join']}{$f['where']} GROUP BY r.source_type, r.source_id ORDER BY {$order} {$dir}, r.source_id ASC LIMIT %d OFFSET %d"; $groups = (array) $wpdb->get_results($wpdb->prepare($sql, $args)); if (empty($groups)) { return array(); } $pairs = array(); $cargs = array(); foreach ($groups as $g) { $pairs[] = '(r.source_type = %s AND r.source_id = %d)'; $cargs[] = (string) $g->source_type; $cargs[] = (int) $g->source_id; } $csql = "SELECT r.source_type, r.source_id, r.score FROM {$wpdb->prefix}aieo_sem_related r WHERE " . implode(' OR ', $pairs) . " ORDER BY r.source_type, r.source_id, r.`rank`"; $scores = (array) $wpdb->get_results($wpdb->prepare($csql, $cargs)); $by_key = array(); foreach ($scores as $s) { $by_key[$s->source_type . ':' . $s->source_id][] = array('score' => (float) $s->score); } $ids = array(); foreach ($groups as $g) { $ids[] = (int) $g->source_id; } if ($ids) { _prime_post_caches(array_values(array_unique($ids)), false, false); } $out = array(); foreach ($groups as $g) { $key = $g->source_type . ':' . $g->source_id; $quality = (class_exists('AIEO_Semantic_Artifacts') && !empty($by_key[$key])) ? AIEO_Semantic_Artifacts::signal_quality($by_key[$key]) : null; $post = get_post((int) $g->source_id); $permalink = $post ? get_permalink($post) : ''; $out[] = array( 'key' => $key, 'source_type' => (string) $g->source_type, 'source_id' => (int) $g->source_id, 'title' => $post ? (string) $post->post_title : '', 'url' => is_string($permalink) ? $permalink : '', 'n' => (int) $g->n, 'quality' => $quality, ); } return $out; } private function read_related_children(array $state) { global $wpdb; list($verdict_col, $verdict_join) = $this->related_verdict_join(); $sql = "SELECT r.source_type, r.source_id, r.`rank`, r.target_type, r.target_id, r.score, r.reason, r.model_version, r.computed_at, {$verdict_col} FROM {$wpdb->prefix}aieo_sem_related r {$verdict_join} WHERE r.source_type = %s AND r.source_id = %d ORDER BY r.`rank`"; return $this->decorate_objects((array) $wpdb->get_results($wpdb->prepare( $sql, $state['src_type'], $state['src_id'] ))); } private function read_clusters($limit) { global $wpdb; if (!$this->table_exists('aieo_sem_clusters')) { return array(); } $members_col = '0 AS member_count'; if ($this->table_exists('aieo_sem_cluster_members')) { $members_col = "(SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_cluster_members m WHERE m.cluster_key = c.cluster_key) AS member_count"; } $state = $this->view_state(); $offset = ($state['page'] - 1) * (int) $limit; $cluster_sortable = array( 'cluster' => 'c.label', 'size' => 'c.member_total', 'share' => 'c.coverage', 'dominance' => 'c.top_share', 'computed' => 'c.computed_at', ); $col = isset($cluster_sortable[$state['orderby']]) ? $cluster_sortable[$state['orderby']] : ''; $dir = ('asc' === $state['order']) ? 'ASC' : 'DESC'; $cluster_order = ('' === $col) ? 'c.member_total DESC, c.cluster_key ASC' : "{$col} {$dir}, c.cluster_key ASC"; return (array) $wpdb->get_results($wpdb->prepare( "SELECT c.*, {$members_col} FROM {$wpdb->prefix}aieo_sem_clusters c ORDER BY {$cluster_order} LIMIT %d OFFSET %d", (int) $limit, $offset )); } private function index_health() { global $wpdb; $health = array( 'objects_total' => null, 'objects_dirty' => null, 'terms' => null, 'postings' => null, 'links_total' => null, 'links_approved' => null, 'related_rows' => null, 'artifact_state' => get_option('aieo_semantic_artifact_state', array()), ); if ($this->table_exists('aieo_sem_objects')) { $health['objects_total'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_objects"); $health['objects_dirty'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_objects WHERE dirty = 1"); } if ($this->table_exists('aieo_sem_terms')) { $health['terms'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_terms"); } if ($this->table_exists('aieo_sem_postings')) { $health['postings'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_postings"); } if ($this->table_exists('aieo_sem_links')) { $health['links_total'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_links"); $health['links_approved'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_links WHERE status = 'approved'"); } if ($this->table_exists('aieo_sem_related')) { $health['related_rows'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_related"); } return $health; } public function ajax_diag_analyze() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!$this->engine_present() || !class_exists('AIEO_Semantic_Tokenizer')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } $word = isset($_POST['word']) ? sanitize_text_field(wp_unslash($_POST['word'])) : ''; if ('' === $word) { wp_send_json_error(array('message' => __('Type a word or phrase to analyze.', 'ai-eshop-optimizer'))); } try { $lang = AIEO_Semantic_Lang_El::create_dev(); } catch (\Throwable $e) { wp_send_json_error(array('message' => __('Could not load the Greek language pack.', 'ai-eshop-optimizer'))); return; } $rows = array(); foreach (AIEO_Semantic_Tokenizer::tokenize($word) as $t) { $norm = $t['norm']; $flags = array(); if ($lang->is_stopword($norm)) { $flags[] = 'stopword'; } $exc = $lang->exception_lemma($norm); if (null !== $exc) { $flags[] = 'exception'; } $split = $lang->split_compound($norm); $compound = ''; if (is_array($split) && count($split) > 1) { $flags[] = 'compound'; $compound = implode(' + ', $split); } $rows[] = array( 'raw' => $t['raw'], 'class' => $t['class'], 'norm' => $norm, 'stem_light' => $lang->stem_light($norm), 'stem_deep' => $lang->stem_deep($norm), 'phonetic' => method_exists($lang, 'phonetic_key_string') ? $lang->phonetic_key_string($norm) : '', 'flags' => implode(', ', $flags), 'compound' => $compound, ); } wp_send_json_success(array( 'rows' => $rows, 'pack' => $lang->pack_info(), )); } public function ajax_save_settings() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_DMM_Settings')) { wp_send_json_error(array('message' => __('Settings store unavailable.', 'ai-eshop-optimizer'))); } $patch = array( 'enabled' => !empty($_POST['enabled']), 'link_injection_enabled' => !empty($_POST['link_injection_enabled']), 'link_suggestion_mode' => isset($_POST['link_suggestion_mode']) && in_array(sanitize_key(wp_unslash($_POST['link_suggestion_mode'])), array('manual', 'auto'), true) ? sanitize_key(wp_unslash($_POST['link_suggestion_mode'])) : 'manual', 'sync_enabled' => !empty($_POST['sync_enabled']), 'lang' => isset($_POST['lang']) && in_array(sanitize_key(wp_unslash($_POST['lang'])), array('auto', 'el', 'en'), true) ? sanitize_key(wp_unslash($_POST['lang'])) : 'auto', 'languages' => self::sanitize_languages( isset($_POST['languages']) ? wp_unslash($_POST['languages']) : array() ), 'lang_rules' => self::sanitize_lang_rules( isset($_POST['lang_rules']) ? wp_unslash($_POST['lang_rules']) : array() ), 'extra_post_types' => self::sanitize_post_types( isset($_POST['extra_post_types']) ? wp_unslash($_POST['extra_post_types']) : array() ), 'tooltip_post_types' => self::sanitize_post_types( isset($_POST['tooltip_post_types']) ? wp_unslash($_POST['tooltip_post_types']) : array() ), 'tooltip_style' => self::sanitize_tooltip_style( isset($_POST['tooltip_style']) ? wp_unslash($_POST['tooltip_style']) : array() ), 'max_links_per_paragraph' => isset($_POST['max_links_per_paragraph']) ? max(1, min(10, (int) $_POST['max_links_per_paragraph'])) : 3, 'max_links_per_page' => isset($_POST['max_links_per_page']) ? max(1, min(100, (int) $_POST['max_links_per_page'])) : 20, 'reco_signal_enabled' => !empty($_POST['reco_signal_enabled']), 'reco_boost_alpha' => isset($_POST['reco_boost_alpha']) ? max(0.0, min(1.0, (float) $_POST['reco_boost_alpha'])) : 0.15, 'staleness_days' => isset($_POST['staleness_days']) ? max(1, min(365, (int) $_POST['staleness_days'])) : 14, 'top_terms_per_object' => isset($_POST['top_terms_per_object']) ? max(20, min(1000, (int) $_POST['top_terms_per_object'])) : 150, 'related_min_score' => isset($_POST['related_min_score']) ? max(0.0, min(5.0, (float) $_POST['related_min_score'])) : 0.0, 'related_min_relative' => isset($_POST['related_min_relative']) ? max(0.0, min(1.0, (float) $_POST['related_min_relative'])) : 0.0, 'related_two_hop_enabled' => !empty($_POST['related_two_hop_enabled']), 'related_two_hop_max_bridge' => isset($_POST['related_two_hop_max_bridge']) ? max(2, min(500, (int) $_POST['related_two_hop_max_bridge'])) : 40, 'related_block_auto' => !empty($_POST['related_block_auto']), 'related_auto_post_types' => array_values(array_diff( self::sanitize_post_types( isset($_POST['related_auto_post_types']) ? wp_unslash($_POST['related_auto_post_types']) : array() ), array('product') )), 'related_block_limit' => isset($_POST['related_block_limit']) ? max(1, min(24, (int) $_POST['related_block_limit'])) : 6, 'related_block_title' => isset($_POST['related_block_title']) ? sanitize_text_field(wp_unslash((string) $_POST['related_block_title'])) : '', 'related_max_per_brand' => isset($_POST['related_max_per_brand']) ? max(0, min(24, (int) $_POST['related_max_per_brand'])) : 2, 'related_max_per_category' => isset($_POST['related_max_per_category']) ? max(0, min(24, (int) $_POST['related_max_per_category'])) : 0, 'audience_mode' => isset($_POST['audience_mode']) && in_array(sanitize_key(wp_unslash($_POST['audience_mode'])), array('off', 'prefer', 'exclude'), true) ? sanitize_key(wp_unslash($_POST['audience_mode'])) : 'off', 'audience_title_markers' => !empty($_POST['audience_title_markers']), 'audience_terms' => self::sanitize_audience_terms( isset($_POST['audience_terms']) ? wp_unslash($_POST['audience_terms']) : array() ), 'audience_grid' => self::sanitize_audience_grid( isset($_POST['audience_grid']) ? wp_unslash($_POST['audience_grid']) : array() ), 'related_time_mode' => isset($_POST['related_time_mode']) && in_array(sanitize_key(wp_unslash($_POST['related_time_mode'])), array('off', 'prefer', 'exclude'), true) ? sanitize_key(wp_unslash($_POST['related_time_mode'])) : 'off', 'related_time_months' => isset($_POST['related_time_months']) ? max(1, min(120, (int) $_POST['related_time_months'])) : 6, 'related_time_post_types' => self::sanitize_post_types( isset($_POST['related_time_post_types']) ? wp_unslash($_POST['related_time_post_types']) : array() ), 'related_time_evergreen_types' => self::sanitize_post_types( isset($_POST['related_time_evergreen_types']) ? wp_unslash($_POST['related_time_evergreen_types']) : array() ), 'related_never_empty' => !empty($_POST['related_never_empty']), 'graph_covisit_nodes' => isset($_POST['graph_covisit_nodes']) ? max(0, min(10, (int) $_POST['graph_covisit_nodes'])) : 3, ); $notice = ''; if ($patch['reco_signal_enabled'] && class_exists('AIEO_Semantic_Reco_Bridge')) { $coverage = (float) AIEO_Semantic_Reco_Bridge::coverage(); if ($coverage < AIEO_Semantic_Reco_Bridge::COVERAGE_MIN_PCT) { $patch['reco_signal_enabled'] = false; $notice = sprintf( __('Recommendations boost NOT enabled: only %1$s%% of products have semantic pair scores (%2$s%% required). Run a full index and artifact sync first.', 'ai-eshop-optimizer'), number_format_i18n($coverage, 1), number_format_i18n(AIEO_Semantic_Reco_Bridge::COVERAGE_MIN_PCT) ); } } if ($patch['sync_enabled'] && class_exists('AIEO_Semantic_Transport') && '' === (string) AIEO_Semantic_Transport::api_key()) { $patch['sync_enabled'] = false; $notice = trim($notice . ' ' . __('Server sync NOT enabled: this site has no Rooster account API key, so the portal cannot issue a data-plane grant. Connect the account first — indexing still runs locally.', 'ai-eshop-optimizer')); } $prev_mode = (string) AIEO_DMM_Settings::get('semantic.link_suggestion_mode', 'manual'); $aieo_all = AIEO_DMM_Settings::get_all(); $aieo_sem = (isset($aieo_all['semantic']) && is_array($aieo_all['semantic'])) ? $aieo_all['semantic'] : array(); $aieo_all['semantic'] = array_merge($aieo_sem, $patch); AIEO_DMM_Settings::replace($aieo_all); if ('auto' === $patch['link_suggestion_mode'] && 'auto' !== $prev_mode && class_exists('AIEO_Semantic_Artifacts') && method_exists('AIEO_Semantic_Artifacts', 'auto_approve_pending')) { $auto_approved = (int) AIEO_Semantic_Artifacts::auto_approve_pending('auto'); if ($auto_approved > 0) { $notice = trim($notice . ' ' . sprintf( __('Auto-accept enabled: %d pending suggestions were approved. They stay listed on the Suggestions tab, where rejecting any of them is still permanent.', 'ai-eshop-optimizer'), $auto_approved )); } } if (class_exists('AIEO_Semantic_Transport')) { if ($patch['sync_enabled']) { AIEO_Semantic_Transport::enable_sync(); } else { AIEO_Semantic_Transport::disable_sync(); } } wp_send_json_success(array('saved' => true, 'settings' => $patch, 'notice' => $notice)); } public function ajax_reindex() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Index_Store') || !method_exists('AIEO_Semantic_Index_Store', 'seed_all_dirty')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } $seeded = AIEO_Semantic_Index_Store::seed_all_dirty(); wp_send_json_success(array( 'seeded' => is_numeric($seeded) ? (int) $seeded : true, 'message' => __('Reindex queued — the data-prep pipeline drains it on its next ticks.', 'ai-eshop-optimizer'), )); } public function ajax_models_load() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Transport') || !method_exists('AIEO_Semantic_Transport', 'models')) { wp_send_json_error(array('message' => __('The semantic transport is not available on this install.', 'ai-eshop-optimizer'))); } $res = AIEO_Semantic_Transport::models(true); if (is_wp_error($res)) { wp_send_json_error(array('message' => $res->get_error_message())); } wp_send_json_success(array('count' => count($res['models']))); } public function ajax_model_save() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Transport') || !method_exists('AIEO_Semantic_Transport', 'set_model')) { wp_send_json_error(array('message' => __('The semantic transport is not available on this install.', 'ai-eshop-optimizer'))); } $model_id = isset($_POST['model_id']) ? sanitize_text_field(wp_unslash((string) $_POST['model_id'])) : ''; $res = AIEO_Semantic_Transport::set_model($model_id); if (is_wp_error($res)) { wp_send_json_error(array('message' => $res->get_error_message())); } wp_send_json_success(array( 'model_id' => $res['model_id'], 'changed' => $res['changed'], 'job_id' => $res['job_id'], 'message' => $res['changed'] ? __('Model changed. The engine is re-analysing this site now; related content keeps showing the previous results until it finishes.', 'ai-eshop-optimizer') : __('That model was already selected, so nothing was re-analysed.', 'ai-eshop-optimizer'), )); } public function ajax_audience_scan() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Audience')) { wp_send_json_error(array('message' => __('The audience policy is not available on this install.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Audience_Lexicon')) { wp_send_json_error(array('message' => __('Rooster One Pro is not installed. The audience marker lexicon ships with the Pro plugin — install and activate it to scan for proposals.', 'ai-eshop-optimizer'))); } $rows = AIEO_Semantic_Audience::scan(); $tax_labels = array(); foreach ($rows as $r) { if (isset($tax_labels[$r['taxonomy']])) { continue; } $obj = get_taxonomy($r['taxonomy']); $tax_labels[$r['taxonomy']] = ($obj && isset($obj->labels->singular_name)) ? (string) $obj->labels->singular_name : $r['taxonomy']; } wp_send_json_success(array( 'rows' => $rows, 'taxonomies' => $tax_labels, 'vocabulary' => AIEO_Semantic_Audience::vocabulary(), )); } public function ajax_decide() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!$this->table_exists('aieo_sem_links')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } global $wpdb; $id = isset($_POST['link_id']) ? (int) $_POST['link_id'] : 0; $decision = isset($_POST['decision']) ? sanitize_key(wp_unslash($_POST['decision'])) : ''; if ($id <= 0 || !in_array($decision, array('approve', 'reject', 'restore', 'edit_anchor', 'edit_target', 'set_present'), true)) { wp_send_json_error(array('message' => __('Invalid decision request.', 'ai-eshop-optimizer'))); } $row = $wpdb->get_row($wpdb->prepare( "SELECT id, source_type, source_id, target_type, target_id, anchor_norm, match_type, status FROM {$wpdb->prefix}aieo_sem_links WHERE id = %d", $id )); if (!$row) { wp_send_json_error(array('message' => __('Suggestion not found — it may have been removed by a newer artifact sync.', 'ai-eshop-optimizer'))); } $now = current_time('mysql', true); $user = get_current_user_id(); if ('edit_anchor' === $decision) { $anchor = isset($_POST['anchor']) ? sanitize_text_field(wp_unslash($_POST['anchor'])) : ''; $anchor = trim(mb_substr($anchor, 0, 191)); $wpdb->update( $wpdb->prefix . 'aieo_sem_links', array( 'edited_anchor' => ('' === $anchor) ? null : $anchor, 'decided_by' => $user, 'decided_at' => $now, ), array('id' => $id) ); $status = (string) $row->status; } elseif ('edit_target' === $decision) { if ('suggested' !== (string) $row->status) { wp_send_json_error(array('message' => __('Only a pending suggestion can be re-targeted. Restore it first if you want to change where it points.', 'ai-eshop-optimizer'))); } $t_type = isset($_POST['target_type']) ? sanitize_key(wp_unslash($_POST['target_type'])) : ''; $t_id = isset($_POST['target_id']) ? (int) $_POST['target_id'] : 0; if ('' === $t_type || $t_id <= 0) { wp_send_json_error(array('message' => __('Pick a target.', 'ai-eshop-optimizer'))); } if (taxonomy_exists($t_type)) { $exists = (bool) get_term($t_id, $t_type) && !is_wp_error(get_term($t_id, $t_type)); } else { $p = get_post($t_id); $exists = ($p && $t_type === get_post_type($p) && 'publish' === get_post_status($p)); } if (!$exists) { wp_send_json_error(array('message' => __('That target no longer exists, or is not published.', 'ai-eshop-optimizer'))); } if ($t_type === (string) $row->source_type && $t_id === (int) $row->source_id) { wp_send_json_error(array('message' => __('A page cannot link to itself.', 'ai-eshop-optimizer'))); } $clash = (int) $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$wpdb->prefix}aieo_sem_links WHERE source_type = %s AND source_id = %d AND target_type = %s AND target_id = %d AND anchor_norm = %s AND id <> %d LIMIT 1", (string) $row->source_type, (int) $row->source_id, $t_type, $t_id, (string) $row->anchor_norm, $id )); if ($clash > 0) { wp_send_json_error(array('message' => __('This page already has a suggestion for that anchor and target — decide that one instead.', 'ai-eshop-optimizer'))); } $wpdb->update( $wpdb->prefix . 'aieo_sem_links', array( 'target_type' => $t_type, 'target_id' => $t_id, 'match_type' => 'retarget', 'decided_by' => $user, 'decided_at' => $now, ), array('id' => $id) ); self::bust_link_caches((string) $row->source_type, (int) $row->source_id); $status = (string) $row->status; } elseif ('set_present' === $decision) { $present = isset($_POST['present']) ? sanitize_text_field(wp_unslash($_POST['present'])) : ''; $present = $this->set_present($id, $present); wp_send_json_success(array('id' => $id, 'status' => (string) $row->status, 'present' => $present)); } else { $map = array('approve' => 'approved', 'reject' => 'rejected', 'restore' => 'suggested'); $status = $map[$decision]; self::decide_rows(array($row), $status, $user, $now); } self::bust_link_caches((string) $row->source_type, (int) $row->source_id); wp_send_json_success(array('id' => $id, 'status' => $status)); } public function ajax_bulk_decide() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!$this->table_exists('aieo_sem_links')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } global $wpdb; $decision = isset($_POST['decision']) ? sanitize_key(wp_unslash($_POST['decision'])) : ''; $ids = isset($_POST['ids']) ? array_filter(array_map('intval', (array) wp_unslash($_POST['ids']))) : array(); $ids = array_slice(array_values(array_unique($ids)), 0, 200); if (!in_array($decision, array('approve', 'reject'), true) || empty($ids)) { wp_send_json_error(array('message' => __('Select at least one suggestion first.', 'ai-eshop-optimizer'))); } $placeholders = implode(',', array_fill(0, count($ids), '%d')); $rows = (array) $wpdb->get_results($wpdb->prepare( "SELECT id, source_type, source_id, target_type, target_id, match_type, status FROM {$wpdb->prefix}aieo_sem_links WHERE id IN ({$placeholders})", $ids )); $target = ('approve' === $decision) ? 'approved' : 'rejected'; $now = current_time('mysql', true); $user = get_current_user_id(); $updated = 0; $skipped = 0; $sources = array(); foreach ($rows as $row) { if ('approve' === $decision && in_array((string) $row->match_type, array('phonetic', 'embedding'), true)) { $skipped++; continue; } if ((string) $row->status === $target) { continue; } $wpdb->update( $wpdb->prefix . 'aieo_sem_links', array('status' => $target, 'decided_by' => $user, 'decided_at' => $now), array('id' => (int) $row->id) ); $updated++; $sources[$row->source_type . ':' . $row->source_id] = array((string) $row->source_type, (int) $row->source_id); if ('approved' === $target) { self::bridge_term_edge($row); } } foreach ($sources as $src) { self::bust_link_caches($src[0], $src[1]); } $message = sprintf( __('%1$d updated, %2$d skipped.', 'ai-eshop-optimizer'), $updated, $skipped ); if ($skipped > 0) { $message .= ' ' . __('Phonetic and embedding matches need per-row approval.', 'ai-eshop-optimizer'); } wp_send_json_success(array('updated' => $updated, 'skipped' => $skipped, 'message' => $message)); } public function ajax_group_rows() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } $surface = (isset($_POST['surface']) && 'related' === sanitize_key(wp_unslash($_POST['surface']))) ? 'related' : 'links'; $table = ('related' === $surface) ? 'aieo_sem_related' : 'aieo_sem_links'; if (!$this->table_exists($table)) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } $state = $this->post_state(); if ('related' === $surface) { if ($state['src_id'] <= 0 || '' === $state['src_type']) { wp_send_json_error(array('message' => __('Unknown group.', 'ai-eshop-optimizer'))); } $rows = $this->read_related_children($state); $scored = array(); foreach ($rows as $r) { $scored[] = array('score' => (float) $r->score); } $quality = (class_exists('AIEO_Semantic_Artifacts') && $scored) ? AIEO_Semantic_Artifacts::signal_quality($scored) : null; require_once AIEO_DIR . 'assets/pages/dmm/partials/related-row.php'; ob_start(); aieo_sem_related_children_table($rows, $quality); wp_send_json_success(array( 'html' => ob_get_clean(), 'shown' => count($rows), 'total' => count($rows), )); } if (('' === $state['anchor']) && ($state['src_id'] <= 0)) { wp_send_json_error(array('message' => __('Unknown group.', 'ai-eshop-optimizer'))); } $state['orderby'] = 'score'; $state['order'] = 'desc'; $state['page'] = 1; $rows = $this->read_links(self::GROUP_ROWS_LIMIT, $state); $total = $this->count_links($state); $partial = AIEO_DIR . 'assets/pages/dmm/partials/object-link.php'; if (file_exists($partial)) { require_once $partial; } ob_start(); foreach ($this->collapse_to_decisions($rows) as $d) { $this->render_group_child_row($d['row'], $d); } wp_send_json_success(array( 'html' => ob_get_clean(), 'shown' => count($rows), 'total' => $total, )); } private function collapse_to_decisions(array $rows) { $out = array(); foreach ($rows as $r) { $dest = ((int) $r->entity_term_id > 0) ? 'tax:' . $r->entity_tax . ':' . (int) $r->entity_term_id : 'obj:' . $r->target_type . ':' . (int) $r->target_id; $key = $r->source_type . ':' . (int) $r->source_id . '|' . $r->anchor_norm . '|' . $dest; if (!isset($out[$key])) { $out[$key] = array( 'row' => $r, 'ids' => array(), 'count' => 0, 'top' => (float) $r->score, 'low' => (float) $r->score, ); } $out[$key]['ids'][] = (int) $r->id; $out[$key]['count']++; $out[$key]['top'] = max($out[$key]['top'], (float) $r->score); $out[$key]['low'] = min($out[$key]['low'], (float) $r->score); } return array_values($out); } private function render_group_child_row($r, $decision = null) { $edited = isset($r->edited_anchor) ? (string) $r->edited_anchor : ''; $current = ('' !== $edited) ? $edited : (string) $r->anchor_text; $ids = ($decision && !empty($decision['ids'])) ? $decision['ids'] : array((int) $r->id); $count = ($decision && !empty($decision['count'])) ? (int) $decision['count'] : 1; ?> source_type . ' #' . (int) $r->source_id); } ?> anchor_text); ?>
target_type . ' #' . (int) $r->target_id); } ?> match_type); ?> score, 3)); ?> 1) : ?>
status); ?> status) { echo AIEO_Kit::button(__('Approve', 'ai-eshop-optimizer'), array('size' => 'sm', 'class' => 'aieo-semlink-decide', 'attrs' => array('data-decision' => 'approve'))) . ' '; } if ('rejected' !== $r->status) { echo AIEO_Kit::button(__('Reject', 'ai-eshop-optimizer'), array('size' => 'sm', 'class' => 'aieo-semlink-decide', 'attrs' => array('data-decision' => 'reject'))) . ' '; } if ('suggested' !== $r->status) { echo AIEO_Kit::button(__('Restore', 'ai-eshop-optimizer'), array('size' => 'sm', 'class' => 'aieo-semlink-decide', 'attrs' => array('data-decision' => 'restore'))) . ' '; } echo AIEO_Kit::button(__('Edit anchor', 'ai-eshop-optimizer'), array('size' => 'sm', 'class' => 'aieo-semlink-edit')); ?> links_filter($state); $sql = "SELECT COUNT(*) FROM {$wpdb->prefix}aieo_sem_links l{$f['join']}{$f['where']}"; return (int) $wpdb->get_var($f['args'] ? $wpdb->prepare($sql, $f['args']) : $sql); } public function ajax_related_curate() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!$this->table_exists('aieo_sem_curation')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } global $wpdb; $surface = isset($_POST['surface']) ? sanitize_key(wp_unslash($_POST['surface'])) : 'related'; $verdict = isset($_POST['verdict']) ? sanitize_key(wp_unslash($_POST['verdict'])) : ''; $source_type = isset($_POST['source_type']) ? sanitize_key(wp_unslash($_POST['source_type'])) : ''; $target_type = isset($_POST['target_type']) ? sanitize_key(wp_unslash($_POST['target_type'])) : ''; $source_id = isset($_POST['source_id']) ? (int) $_POST['source_id'] : 0; $target_id = isset($_POST['target_id']) ? (int) $_POST['target_id'] : 0; if (!in_array($surface, array('related', 'reco'), true) || !in_array($verdict, array('pin', 'ban', 'clear'), true) || '' === $source_type || '' === $target_type || $source_id <= 0 || $target_id <= 0) { wp_send_json_error(array('message' => __('Invalid curation request.', 'ai-eshop-optimizer'))); } $table = $wpdb->prefix . 'aieo_sem_curation'; if ('clear' === $verdict) { $wpdb->delete($table, array( 'surface' => $surface, 'source_type' => $source_type, 'source_id' => $source_id, 'target_type' => $target_type, 'target_id' => $target_id, )); } else { $wpdb->query($wpdb->prepare( "INSERT INTO `{$table}` (surface, source_type, source_id, target_type, target_id, verdict, decided_by, decided_at) VALUES (%s,%s,%d,%s,%d,%s,%d,%s) ON DUPLICATE KEY UPDATE verdict = VALUES(verdict), decided_by = VALUES(decided_by), decided_at = VALUES(decided_at)", $surface, $source_type, $source_id, $target_type, $target_id, $verdict, get_current_user_id(), current_time('mysql', true) )); } if (class_exists('AIEO_Semantic_Artifacts')) { AIEO_Semantic_Artifacts::bump_cache(); } wp_send_json_success(array('verdict' => ('clear' === $verdict) ? '' : $verdict)); } public function ajax_backfill_start() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Backfill') || !AIEO_Semantic_Backfill::engine_ready()) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } $seed_all = !empty($_POST['seed_all']); $res = AIEO_Semantic_Backfill::start_onboarding($seed_all, 'ajax'); if (empty($res['started'])) { $msgs = array( 'already_running' => __('A backfill run is already in progress.', 'ai-eshop-optimizer'), 'indexing_disabled' => __('Semantic indexing is switched off — enable it above and save settings first.', 'ai-eshop-optimizer'), 'engine_absent' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'), ); $msg = isset($msgs[$res['reason']]) ? $msgs[$res['reason']] : __('Could not start the backfill.', 'ai-eshop-optimizer'); wp_send_json_error(array('message' => $msg, 'reason' => $res['reason'])); } wp_send_json_success($res); } public function ajax_backfill_step() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Backfill') || !AIEO_Semantic_Backfill::engine_ready()) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } $r = AIEO_Semantic_Backfill::tick_once(array('onboard')); wp_send_json_success(array_merge( $r, AIEO_Semantic_Backfill::runner_status() )); } public function ajax_graph_search() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!$this->table_exists('aieo_sem_related')) { wp_send_json_success(array('results' => array())); } global $wpdb; $q = isset($_POST['q']) ? sanitize_text_field(wp_unslash($_POST['q'])) : ''; if (mb_strlen($q) < 2) { wp_send_json_success(array('results' => array())); } $like = '%' . $wpdb->esc_like($q) . '%'; $rows = (array) $wpdb->get_results($wpdb->prepare( "SELECT r.source_type, r.source_id, p.post_title AS title, COUNT(*) AS n FROM {$wpdb->prefix}aieo_sem_related r JOIN {$wpdb->posts} p ON p.ID = r.source_id AND CONVERT(p.post_type USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(r.source_type USING utf8mb4) COLLATE utf8mb4_general_ci AND p.post_status = 'publish' WHERE p.post_title LIKE %s GROUP BY r.source_type, r.source_id, p.post_title ORDER BY n DESC, p.post_title LIMIT 25", $like )); $out = array(); foreach ($rows as $r) { $out[] = array( 'id' => (int) $r->source_id, 'type' => (string) $r->source_type, 'title' => (string) $r->title, 'n' => (int) $r->n, ); } wp_send_json_success(array('results' => $out)); } public function ajax_object_search() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } global $wpdb; $q = isset($_POST['q']) ? sanitize_text_field(wp_unslash($_POST['q'])) : ''; $surface = (isset($_POST['surface']) && 'related' === sanitize_key(wp_unslash($_POST['surface']))) ? 'related' : 'links'; $status = isset($_POST['status']) ? sanitize_key(wp_unslash($_POST['status'])) : 'suggested'; if (mb_strlen($q) < 2) { wp_send_json_success(array('results' => array())); } $table = ('related' === $surface) ? 'aieo_sem_related' : 'aieo_sem_links'; if (!$this->table_exists($table)) { wp_send_json_success(array('results' => array())); } if (!in_array($status, array('suggested', 'approved', 'rejected', 'all'), true)) { $status = 'suggested'; } $where = ''; $args = array(); if ('links' === $table && 'all' !== $status) { $where = ' AND r.status = %s'; $args[] = $status; } $like = '%' . $wpdb->esc_like($q) . '%'; $args[] = $like; $sql = "SELECT r.source_type, r.source_id, p.post_title AS title, COUNT(*) AS n FROM {$wpdb->prefix}{$table} r JOIN {$wpdb->posts} p ON p.ID = r.source_id AND CONVERT(p.post_type USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(r.source_type USING utf8mb4) COLLATE utf8mb4_general_ci WHERE 1 = 1{$where} AND p.post_title LIKE %s GROUP BY r.source_type, r.source_id, p.post_title ORDER BY n DESC, p.post_title LIMIT 25"; $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $args)); $out = array(); foreach ($rows as $r) { $out[] = array( 'id' => (int) $r->source_id, 'type' => (string) $r->source_type, 'title' => (string) $r->title, 'n' => (int) $r->n, ); } wp_send_json_success(array('results' => $out)); } public function ajax_cluster_graph() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Artifacts') || !class_exists('AIEO_Semantic_Graph_Block')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } $key = isset($_POST['cluster_key']) ? sanitize_text_field(wp_unslash($_POST['cluster_key'])) : ''; $nodes = isset($_POST['nodes']) ? max(2, min(100, (int) $_POST['nodes'])) : 20; if ('' === $key) { wp_send_json_error(array('message' => __('Unknown cluster.', 'ai-eshop-optimizer'))); } $graph = AIEO_Semantic_Artifacts::cluster_graph($key, $nodes); if (empty($graph['nodes'])) { wp_send_json_error(array('message' => __('Not enough published members to draw this cluster.', 'ai-eshop-optimizer'))); } ob_start(); $html = AIEO_Semantic_Graph_Block::render_payload($graph, '2d', 0); $stray = (string) ob_get_clean(); wp_send_json_success(array( 'html' => $stray . $html, 'nodes' => count($graph['nodes']), 'links' => count($graph['links']), )); } public function ajax_backfill_status() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Backfill')) { wp_send_json_error(array('message' => __('Backfill runner unavailable.', 'ai-eshop-optimizer'))); } wp_send_json_success(array_merge( AIEO_Semantic_Backfill::runner_status(), array('preview' => AIEO_Semantic_Backfill::preview()) )); } private function graph_choices($limit) { global $wpdb; if (!$this->table_exists('aieo_sem_related')) { return array(); } return (array) $wpdb->get_results($wpdb->prepare( "SELECT r.source_type, r.source_id, p.post_title AS title, COUNT(*) AS n FROM {$wpdb->prefix}aieo_sem_related r JOIN {$wpdb->posts} p ON p.ID = r.source_id AND CONVERT(p.post_type USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(r.source_type USING utf8mb4) COLLATE utf8mb4_general_ci AND p.post_status = 'publish' GROUP BY r.source_type, r.source_id, p.post_title ORDER BY n DESC, p.post_title LIMIT %d", (int) $limit )); } public static function decide_rows(array $rows, $status, $decided_by = 0, $decided_at = '') { global $wpdb; if (!in_array($status, array('approved', 'rejected', 'suggested'), true)) { return 0; } if ('' === (string) $decided_at) { $decided_at = current_time('mysql', true); } $ids = array(); $bridge = array(); $sources = array(); foreach ($rows as $row) { if (!is_object($row) || empty($row->id)) { continue; } if (isset($row->status) && (string) $row->status === $status) { continue; } $ids[] = (int) $row->id; $sources[$row->source_type . ':' . $row->source_id] = array((string) $row->source_type, (int) $row->source_id); if ('approved' === $status) { $bridge[] = $row; } } if (empty($ids)) { return 0; } $ph = implode(',', array_fill(0, count($ids), '%d')); $args = array_merge(array($status, (int) $decided_by, (string) $decided_at), $ids); $updated = (int) $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->prefix}aieo_sem_links SET status = %s, decided_by = %d, decided_at = %s WHERE id IN ({$ph})", $args )); foreach ($bridge as $row) { self::bridge_term_edge($row); } foreach ($sources as $src) { self::bust_link_caches($src[0], $src[1]); } return $updated; } private static function bridge_term_edge($row) { if (!class_exists('AIEO_DMM_Term_Relations') || !taxonomy_exists((string) $row->source_type)) { return; } $kind = taxonomy_exists((string) $row->target_type) ? 'term' : 'post'; AIEO_DMM_Term_Relations::add_edge( (string) $row->source_type, (int) $row->source_id, $kind, (string) $row->target_type, (int) $row->target_id ); } public function ajax_pair_scan() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Translation_Store')) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Lang_Registry') || !AIEO_Semantic_Lang_Registry::is_multilingual()) { wp_send_json_error(array('message' => __('This site has one language. Add another under Settings → Other languages first.', 'ai-eshop-optimizer'))); } if (AIEO_Semantic_Lang_Registry::translations_owned_elsewhere()) { wp_send_json_error(array('message' => __('Your translation plugin manages translations on this site — pair content there instead.', 'ai-eshop-optimizer'))); } $apply = !empty($_POST['apply']); $offset = isset($_POST['offset']) ? max(0, (int) $_POST['offset']) : 0; $report = AIEO_Semantic_Translation_Store::scan(array( 'apply' => $apply, 'offset' => $offset, )); if (!empty($report['error'])) { wp_send_json_error(array('message' => __('This site has one language. Add another under Settings → Other languages first.', 'ai-eshop-optimizer'))); } wp_send_json_success($report); } private static function sanitize_languages($raw) { if (!is_array($raw)) { return array(); } $supported = class_exists('AIEO_Semantic_Lang_Registry') ? AIEO_Semantic_Lang_Registry::SUPPORTED : array('el', 'en'); $out = array(); foreach ($raw as $code) { $code = sanitize_key((string) $code); if (in_array($code, $supported, true) && !in_array($code, $out, true)) { $out[] = $code; } } return $out; } private static function sanitize_lang_rules($raw) { if (!is_array($raw)) { return array(); } $out = array(); foreach ($raw as $rule) { if (count($out) >= 20) { break; } if (!is_array($rule) || !isset($rule['path'])) { continue; } $path = trim(sanitize_text_field((string) $rule['path'])); if ('' === $path || '' === trim($path, '/')) { continue; } $lang = isset($rule['lang']) ? sanitize_key((string) $rule['lang']) : ''; if (!in_array($lang, array('el', 'en'), true)) { continue; } $out[] = array('path' => $path, 'lang' => $lang); } return $out; } public static function rule_table(array $rules, array $view) { $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $per = max(1, (int) ($view['per_page'] ?? 50)); $empty = (int) ($view['all'] ?? 0) > 0 ? \Rooster\Admin\States::state('no-results', array('compact' => true)) : \Rooster\Admin\States::state('first-use', array('compact' => true, 'title' => __('No rules yet. Add one above, or import the ones Internal Link Juicer already holds.', 'ai-eshop-optimizer'))); return \Rooster\Admin\Ui::table( self::rule_columns((string) ($view['orderby'] ?? ''), (string) ($view['order'] ?? 'asc')), self::rule_cells((array) ($rules['rows'] ?? array()), !empty($view['hits_built'])), array( 'id' => 'aieo-rules-table', 'caption' => __('Anchor rules: always link this word here', 'ai-eshop-optimizer'), 'empty' => $empty, 'search' => array('label' => __('Search', 'ai-eshop-optimizer'), 'placeholder' => __('Search anchors…', 'ai-eshop-optimizer')), 'filters' => self::rule_filters(), 'filter_values' => array('present' => (string) ($view['present'] ?? ''), 'origin' => (string) ($view['origin'] ?? '')), 'remote' => array( 'url' => add_query_arg('action', 'aieo_dmm_semantic_rule_rows', admin_url('admin-ajax.php')), 'nonce' => $nonce, 'context' => 'admin', 'size' => $per, 'sizes' => array(25, 50, 100), 'total' => (int) ($rules['total'] ?? 0), 'paged' => max(1, (int) ($view['page'] ?? 1)), 'prefix' => 'sem_', 'keys' => array('s' => 'sem_q', 'orderby' => 'sem_orderby', 'order' => 'sem_order', 'paged' => 'sem_page', 'per_page' => 'sem_per_page'), ), 'footer' => (int) ($rules['total'] ?? 0) > $per ? \Rooster\Admin\Ui::pager( max(1, (int) ($view['page'] ?? 1)), (int) ceil((int) $rules['total'] / $per), static function ($n) { return add_query_arg('sem_page', $n > 1 ? $n : false); }, array('total' => (int) $rules['total']) ) : '', ) ); } public function ajax_rule_rows() { if (!check_ajax_referer(AIEO_DMM::NONCE_ACTION, '_wpnonce', false) || !current_user_can(AIEO_CAPABILITY)) { wp_send_json(array('message' => __('Insufficient permissions.', 'ai-eshop-optimizer')), 403); } if (!class_exists('AIEO_Semantic_Link_Rules') || (!class_exists('\\Rooster\\Admin\\Ui') && (!function_exists('rooster_admin_builders') || !rooster_admin_builders()))) { wp_send_json(array('message' => __('Anchor rules are part of the Greek semantic engine in Rooster One Pro. Install and activate it to use this screen.', 'ai-eshop-optimizer')), 400); } $params = isset($_GET['params']) && is_array($_GET['params']) ? wp_unslash($_GET['params']) : array(); $args = array( 'origin' => isset($params['origin']) && is_scalar($params['origin']) ? sanitize_key((string) $params['origin']) : '', 'q' => isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '', 'present' => isset($params['present']) && is_scalar($params['present']) ? sanitize_text_field((string) $params['present']) : '', 'orderby' => isset($_GET['orderby']) ? sanitize_key(wp_unslash($_GET['orderby'])) : '', 'order' => isset($_GET['order']) && 'desc' === sanitize_key(wp_unslash($_GET['order'])) ? 'desc' : 'asc', 'page' => isset($_GET['page']) ? max(1, absint($_GET['page'])) : 1, 'per_page' => isset($_GET['per_page']) ? max(1, min(200, absint($_GET['per_page']))) : 50, ); $list = AIEO_Semantic_Link_Rules::page($args); $stats = AIEO_Semantic_Link_Rules::hits_stats(); wp_send_json(\Rooster\Admin\Ui::answer( self::rule_columns($args['orderby'], $args['order']), self::rule_cells((array) ($list['rows'] ?? array()), '' !== (string) ($stats['built'] ?? '')), array( 'filtered' => (int) ($list['total'] ?? 0), 'page' => $args['page'], 'per_page' => $args['per_page'], 'orderby' => $args['orderby'], 'order' => $args['order'], ) )); } private static function rule_columns($orderby, $order) { $col = static function ($key, $label) use ($orderby, $order) { $on = ($orderby === $key); return array( 'key' => $key, 'label' => $label, 'type' => 'text', 'sort' => $on ? ('desc' === $order ? 'desc' : 'asc') : 'none', 'sort_type' => 'usage' === $key ? 'number' : 'text', 'sort_url' => add_query_arg(array('sem_orderby' => $key, 'sem_order' => ($on && 'asc' === $order) ? 'desc' : 'asc', 'sem_page' => false)), ); }; return array( $col('anchor', __('When this appears', 'ai-eshop-optimizer')), $col('target', __('It links to', 'ai-eshop-optimizer')), $col('present', __('Shown as', 'ai-eshop-optimizer')), $col('usage', __('Used on', 'ai-eshop-optimizer')), $col('origin', __('Origin', 'ai-eshop-optimizer')), $col('enabled', __('Applied', 'ai-eshop-optimizer')), array('key' => 'actions', 'label' => __('Actions', 'ai-eshop-optimizer'), 'type' => 'actions'), ); } private static function rule_filters() { return array( array( 'key' => 'present', 'label' => __('Shown as', 'ai-eshop-optimizer'), 'control' => 'select', 'any' => __('Shown as: anything', 'ai-eshop-optimizer'), 'options' => array( 'tooltip' => __('Only tooltips', 'ai-eshop-optimizer'), 'link' => __('Only plain links', 'ai-eshop-optimizer'), 'link+preview' => __('Only link + preview', 'ai-eshop-optimizer'), ), ), array( 'key' => 'origin', 'label' => __('Origin', 'ai-eshop-optimizer'), 'control' => 'select', 'any' => __('Every origin', 'ai-eshop-optimizer'), 'options' => array( 'manual' => __('Written here', 'ai-eshop-optimizer'), 'ilj' => __('Imported from Internal Link Juicer', 'ai-eshop-optimizer'), ), ), ); } private static function rule_cells(array $rows, $hits_built) { $present_label = static function ($value) { switch ($value) { case 'tooltip': return __('Tooltip', 'ai-eshop-optimizer'); case 'link+preview': return __('Link + preview', 'ai-eshop-optimizer'); default: return __('Link', 'ai-eshop-optimizer'); } }; $out = array(); foreach ($rows as $r) { $used = isset($r['usage_count']) ? (int) $r['usage_count'] : 0; if ($used > 0) { $used_html = \Rooster\Admin\Ui::button(sprintf(_n('%s page', '%s pages', $used, 'ai-eshop-optimizer'), number_format_i18n($used)), array('variant' => 'ghost', 'size' => 'sm', 'class' => 'aieo-rule-usage')); } elseif (!$hits_built) { $used_html = '—'; } else { $used_html = \Rooster\Admin\Ui::badge(__('nowhere yet', 'ai-eshop-optimizer'), 'warning', array('attrs' => array('title' => __('No published page contains this text yet. The rule still applies to anything published later.', 'ai-eshop-optimizer')))); } $out[] = array( '_id' => (string) (int) $r['id'], '_name' => (string) $r['anchor_text'], '_attrs' => array( 'data-id' => (int) $r['id'], 'data-anchor' => (string) $r['anchor_text'], 'data-target-type' => (string) $r['target_type'], 'data-target-id' => (int) $r['target_id'], 'data-target-label' => (string) $r['target_label'], 'data-present' => (string) $r['present'], 'data-lang' => (string) $r['lang'], ), 'anchor' => array('html' => '' . esc_html($r['anchor_text']) . '' . ('' !== $r['lang'] ? ' · ' . esc_html($r['lang']) . '' : '')), 'target' => array('html' => ('' !== $r['target_url'] ? '' . esc_html($r['target_label']) . '' : \Rooster\Admin\Ui::badge(__('target is gone', 'ai-eshop-optimizer'), 'destructive')) . '' . esc_html($r['target_type'] . ' #' . (int) $r['target_id']) . ''), 'present' => array('html' => esc_html($present_label($r['present_eff'])) . ('' === $r['present'] ? ' ' . esc_html__('(automatic)', 'ai-eshop-optimizer') . '' : '')), 'usage' => array('html' => $used_html), 'origin' => 'ilj' === $r['origin'] ? __('Imported', 'ai-eshop-optimizer') : __('Written here', 'ai-eshop-optimizer'), 'enabled' => array('html' => ''), 'actions' => '' . \Rooster\Admin\Ui::button(__('Edit', 'ai-eshop-optimizer'), array('size' => 'sm', 'class' => 'aieo-rule-edit', 'attrs' => array('aria-label' => sprintf(__('Edit %s', 'ai-eshop-optimizer'), $r['anchor_text'])))) . \Rooster\Admin\Ui::button(__('Delete', 'ai-eshop-optimizer'), array('size' => 'sm', 'class' => 'aieo-rule-delete is-destructive', 'attrs' => array('aria-label' => sprintf(__('Delete %s', 'ai-eshop-optimizer'), $r['anchor_text'])))) . '', ); } return $out; } private function present_options() { return array( '' => __('Automatic — decide from what the target is', 'ai-eshop-optimizer'), 'link' => __('Link — clicking goes to the page', 'ai-eshop-optimizer'), 'tooltip' => __('Tooltip — hovering shows the content, clicking does not leave the page', 'ai-eshop-optimizer'), 'link+preview' => __('Link with preview — hovering shows it, clicking still goes there', 'ai-eshop-optimizer'), ); } private function target_type_labels() { $out = array(); foreach (get_post_types(array('public' => true), 'objects') as $type => $object) { if (class_exists('AIEO_Semantic_Link_Rules') && !AIEO_Semantic_Link_Rules::type_linkable($type)) { continue; } $out[$type] = isset($object->labels->singular_name) ? (string) $object->labels->singular_name : $type; } foreach (get_taxonomies(array('public' => true), 'objects') as $tax => $object) { if (empty($object->show_in_nav_menus) || isset($out[$tax])) { continue; } $out[$tax] = isset($object->labels->singular_name) ? (string) $object->labels->singular_name : $tax; } return $out; } private function rule_language_choices() { if (!class_exists('AIEO_Semantic_Lang_Registry') || !method_exists('AIEO_Semantic_Lang_Registry', 'is_multilingual') || !AIEO_Semantic_Lang_Registry::is_multilingual()) { return array(); } $out = array('' => __('Any language', 'ai-eshop-optimizer')); foreach (array('en' => __('English', 'ai-eshop-optimizer'), 'el' => __('Greek', 'ai-eshop-optimizer')) as $code => $label) { $out[$code] = $label; } return $out; } private static function sanitize_post_types($in) { $out = array(); foreach ((array) $in as $type) { $type = sanitize_key((string) $type); if ('' === $type || in_array($type, $out, true)) { continue; } if (!post_type_exists($type)) { continue; } $out[] = $type; if (count($out) >= 30) { break; } } return $out; } private static function sanitize_audience_terms($in) { if (!is_array($in)) { return array(); } $vocab = class_exists('AIEO_Semantic_Audience') ? AIEO_Semantic_Audience::vocabulary() : array('male', 'female', 'unisex', 'kids', 'unknown'); $out = array(); foreach ($in as $key => $aud) { if (!preg_match('#^([a-z0-9_\-]+):([0-9]+)$#', (string) $key, $m)) { continue; } $aud = sanitize_key((string) $aud); if (!in_array($aud, $vocab, true) || 'unknown' === $aud) { continue; } if (!taxonomy_exists($m[1])) { continue; } $out[$m[1] . ':' . (int) $m[2]] = $aud; if (count($out) >= 2000) { break; } } return $out; } private static function sanitize_audience_grid($in) { if (!is_array($in)) { return array(); } $vocab = class_exists('AIEO_Semantic_Audience') ? AIEO_Semantic_Audience::vocabulary() : array('male', 'female', 'unisex', 'kids', 'unknown'); $out = array(); foreach ($in as $anchor => $targets) { $anchor = sanitize_key((string) $anchor); if (!in_array($anchor, $vocab, true)) { continue; } $row = array(); foreach ((array) $targets as $t) { $t = sanitize_key((string) $t); if (in_array($t, $vocab, true) && !in_array($t, $row, true)) { $row[] = $t; } } if (empty($row)) { continue; } if (!in_array('unknown', $row, true)) { $row[] = 'unknown'; } $out[$anchor] = $row; } return $out; } private static function sanitize_tooltip_style($in) { $in = is_array($in) ? $in : array(); $out = array(); foreach (array('light', 'dark') as $scheme) { foreach (array('bg', 'text', 'link', 'border') as $part) { $key = $scheme . '_' . $part; $value = isset($in[$key]) ? trim((string) $in[$key]) : ''; $out[$key] = ('' === $value) ? '' : (string) sanitize_hex_color($value); } } $width = isset($in['max_width']) ? (int) $in['max_width'] : 0; $out['max_width'] = ($width > 0) ? max(200, min(700, $width)) : 0; return $out; } private function selectable_post_types() { $always = class_exists('AIEO_Semantic_Index') ? AIEO_Semantic_Index::POST_TYPES : array('product', 'post', 'page'); $out = array(); foreach (get_post_types(array('public' => true), 'objects') as $type => $object) { if (class_exists('AIEO_Semantic_Link_Rules') && !AIEO_Semantic_Link_Rules::type_linkable($type)) { continue; } $out[$type] = array( 'label' => isset($object->labels->singular_name) ? (string) $object->labels->singular_name : $type, 'always' => in_array($type, $always, true), ); } return $out; } private function auto_placement_post_types() { $out = array(); foreach (get_post_types(array('public' => true), 'objects') as $type => $object) { if ('product' === $type || 'attachment' === $type) { continue; } if (function_exists('is_post_type_viewable') && !is_post_type_viewable($type)) { continue; } $out[$type] = isset($object->labels->singular_name) ? (string) $object->labels->singular_name : $type; } return $out; } private function require_rules_engine() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!class_exists('AIEO_Semantic_Link_Rules') || !AIEO_Semantic_Link_Rules::table_ready()) { wp_send_json_error(array('message' => __('Greek semantic engine not loaded — install and activate Rooster One Pro.', 'ai-eshop-optimizer'))); } } public function ajax_rule_save() { $this->require_rules_engine(); $result = AIEO_Semantic_Link_Rules::save(array( 'id' => isset($_POST['rule_id']) ? (int) $_POST['rule_id'] : 0, 'anchor' => isset($_POST['anchor']) ? sanitize_text_field(wp_unslash($_POST['anchor'])) : '', 'target_type' => isset($_POST['target_type']) ? sanitize_key(wp_unslash($_POST['target_type'])) : '', 'target_id' => isset($_POST['target_id']) ? (int) $_POST['target_id'] : 0, 'present' => isset($_POST['present']) ? sanitize_text_field(wp_unslash($_POST['present'])) : '', 'lang' => isset($_POST['lang']) ? sanitize_key(wp_unslash($_POST['lang'])) : '', 'note' => isset($_POST['note']) ? sanitize_text_field(wp_unslash($_POST['note'])) : '', 'enabled' => isset($_POST['enabled']) ? (int) $_POST['enabled'] : 1, )); if (empty($result['ok'])) { wp_send_json_error(array( 'message' => (string) $result['message'], 'error' => (string) $result['error'], 'conflict' => $result['conflict'], )); } $this->bust_rule_caches(); $hits = AIEO_Semantic_Link_Rules::rebuild_for_rule((int) $result['id']); wp_send_json_success(array( 'id' => (int) $result['id'], 'hits' => $hits, 'message' => sprintf( _n( 'Rule saved — it links %d page right now, and any page published later that uses the word.', 'Rule saved — it links %d pages right now, and any page published later that uses the word.', $hits, 'ai-eshop-optimizer' ), $hits ), )); } public function ajax_rule_delete() { $this->require_rules_engine(); $id = isset($_POST['rule_id']) ? (int) $_POST['rule_id'] : 0; if (!AIEO_Semantic_Link_Rules::delete($id)) { wp_send_json_error(array('message' => __('Rule not found.', 'ai-eshop-optimizer'))); } AIEO_Semantic_Link_Rules::rebuild_for_rule($id); $this->bust_rule_caches(); wp_send_json_success(array('id' => $id)); } public function ajax_rule_toggle() { $this->require_rules_engine(); $id = isset($_POST['rule_id']) ? (int) $_POST['rule_id'] : 0; $enabled = !empty($_POST['enabled']); if (!AIEO_Semantic_Link_Rules::set_enabled($id, $enabled)) { wp_send_json_error(array('message' => __('Rule not found.', 'ai-eshop-optimizer'))); } $this->bust_rule_caches(); $hits = AIEO_Semantic_Link_Rules::rebuild_for_rule($id); wp_send_json_success(array('id' => $id, 'enabled' => $enabled ? 1 : 0, 'hits' => $hits)); } public function ajax_target_search() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } $q = isset($_POST['q']) ? sanitize_text_field(wp_unslash($_POST['q'])) : ''; $type = isset($_POST['target_type']) ? sanitize_key(wp_unslash($_POST['target_type'])) : ''; if (mb_strlen($q) < 2) { wp_send_json_success(array('results' => array())); } $allowed = $this->target_type_labels(); if ('' === $type || !isset($allowed[$type])) { wp_send_json_error(array('message' => __('Pick what kind of thing to link to first.', 'ai-eshop-optimizer'))); } $out = array(); if (post_type_exists($type)) { $posts = get_posts(array( 'post_type' => $type, 'post_status' => 'publish', 's' => $q, 'numberposts' => 25, 'orderby' => 'title', 'order' => 'ASC', 'suppress_filters' => false, )); foreach ($posts as $post) { $out[] = array( 'id' => (int) $post->ID, 'title' => (string) get_the_title($post), 'url' => (string) get_permalink($post), ); } } elseif (taxonomy_exists($type)) { $terms = get_terms(array( 'taxonomy' => $type, 'search' => $q, 'number' => 25, 'hide_empty' => false, )); if (!is_wp_error($terms)) { foreach ($terms as $term) { $link = get_term_link($term); $out[] = array( 'id' => (int) $term->term_id, 'title' => (string) $term->name, 'url' => (is_string($link) && !is_wp_error($link)) ? $link : '', ); } } } wp_send_json_success(array('results' => $out)); } public function ajax_rule_usage() { $this->require_rules_engine(); $id = isset($_POST['rule_id']) ? (int) $_POST['rule_id'] : 0; if ($id <= 0) { wp_send_json_error(array('message' => __('Rule not found.', 'ai-eshop-optimizer'))); } wp_send_json_success(AIEO_Semantic_Link_Rules::usage($id, 100)); } public function ajax_rule_map() { $this->require_rules_engine(); $offset = isset($_POST['offset']) ? max(0, (int) $_POST['offset']) : 0; $size = isset($_POST['size']) ? max(1, min(300, (int) $_POST['size'])) : 100; wp_send_json_success(AIEO_Semantic_Link_Rules::rebuild_chunk($offset, $size)); } public function ajax_ilj_scan() { $this->require_rules_engine(); if (!class_exists('AIEO_Semantic_Ilj_Import')) { wp_send_json_error(array('message' => __('The importer is not available.', 'ai-eshop-optimizer'))); } wp_send_json_success(AIEO_Semantic_Ilj_Import::scan()); } public function ajax_ilj_import() { $this->require_rules_engine(); if (!class_exists('AIEO_Semantic_Ilj_Import')) { wp_send_json_error(array('message' => __('The importer is not available.', 'ai-eshop-optimizer'))); } $result = AIEO_Semantic_Ilj_Import::apply(); $this->bust_rule_caches(); $result['message'] = sprintf( __('%1$s rules created, %2$s keywords skipped. Rules apply immediately — no reindex needed.', 'ai-eshop-optimizer'), number_format_i18n((int) $result['created']), number_format_i18n((int) $result['skipped']) ); wp_send_json_success($result); } private function bust_rule_caches() { if (class_exists('AIEO_Semantic_Link_Rules')) { AIEO_Semantic_Link_Rules::bust(); } if (class_exists('AIEO_Semantic_Artifacts')) { AIEO_Semantic_Artifacts::bump_cache(); } } private static function bust_link_caches($source_type, $source_id) { if (class_exists('AIEO_Semantic_Link_Injector')) { AIEO_Semantic_Link_Injector::bust_meta($source_type, $source_id); } if (class_exists('AIEO_Semantic_Artifacts')) { AIEO_Semantic_Artifacts::bump_cache(); } } private function reco_diagnostics() { if (!class_exists('AIEO_Semantic_Reco_Bridge')) { return array(); } $enabled = false; $alpha = 0.15; if (class_exists('AIEO_Semantic_Settings')) { $enabled = (bool) AIEO_Semantic_Settings::get('reco_signal_enabled', false); $alpha = (float) AIEO_Semantic_Settings::get('reco_boost_alpha', 0.15); } return array( 'enabled' => $enabled, 'alpha' => $alpha, 'coverage' => (float) AIEO_Semantic_Reco_Bridge::coverage(), 'min_pct' => (float) AIEO_Semantic_Reco_Bridge::COVERAGE_MIN_PCT, 'hits' => (int) AIEO_Semantic_Reco_Bridge::counter(AIEO_Semantic_Reco_Bridge::COUNTER_HIT), 'misses' => (int) AIEO_Semantic_Reco_Bridge::counter(AIEO_Semantic_Reco_Bridge::COUNTER_MISS), ); } private function mwe_queue() { global $wpdb; $out = array('candidates' => array(), 'counts' => array()); $table = $wpdb->prefix . 'aieo_sem_mwe'; if (!$this->table_exists('aieo_sem_mwe')) { return $out; } $counts = $wpdb->get_results("SELECT status, COUNT(*) n FROM `{$table}` GROUP BY status", ARRAY_A); foreach ((array) $counts as $c) { $out['counts'][(string) $c['status']] = (int) $c['n']; } $out['candidates'] = (array) $wpdb->get_results( "SELECT id, term, parts, df, npmi, cond_prob, noncomp, sample FROM `{$table}` WHERE status = 'candidate' ORDER BY npmi DESC, term ASC LIMIT 30", ARRAY_A ); return $out; } public function ajax_mwe_decide() { AIEO_DMM::verify_ajax_request(); if (!self::is_aieo_pro_user()) { wp_send_json_error(array('message' => __('This feature requires the Pro version.', 'ai-eshop-optimizer'))); } if (!$this->table_exists('aieo_sem_mwe')) { wp_send_json_error(array('message' => __('No discovery data yet — run a sync first.', 'ai-eshop-optimizer'))); } global $wpdb; $id = isset($_POST['id']) ? (int) $_POST['id'] : 0; $decision = isset($_POST['decision']) ? sanitize_key(wp_unslash($_POST['decision'])) : ''; $map = array('approve' => 'approved', 'reject' => 'rejected', 'restore' => 'candidate'); if ($id <= 0 || !isset($map[$decision])) { wp_send_json_error(array('message' => __('Invalid request.', 'ai-eshop-optimizer'))); } $table = $wpdb->prefix . 'aieo_sem_mwe'; $row = $wpdb->get_row($wpdb->prepare("SELECT id, term, status FROM `{$table}` WHERE id = %d", $id)); if (!$row) { wp_send_json_error(array('message' => __('Candidate not found.', 'ai-eshop-optimizer'))); } $was_approved = ('approved' === $row->status); $wpdb->update($table, array( 'status' => $map[$decision], 'decided_by' => get_current_user_id(), 'decided_at' => current_time('mysql', true), ), array('id' => $id)); $now_approved = ('approved' === $map[$decision]); if ($now_approved || $was_approved) { if (class_exists('AIEO_Semantic_Index') && method_exists('AIEO_Semantic_Index', 'flush_entities')) { AIEO_Semantic_Index::flush_entities(); } update_option('aieo_sem_vocab_rev', (int) get_option('aieo_sem_vocab_rev', 0) + 1, false); $objects = $wpdb->prefix . 'aieo_sem_objects'; $postings = $wpdb->prefix . 'aieo_sem_postings'; $terms = $wpdb->prefix . 'aieo_sem_terms'; $wpdb->query($wpdb->prepare( "UPDATE `{$objects}` o JOIN `{$postings}` p ON p.object_type = o.object_type AND p.object_id = o.object_id JOIN `{$terms}` t ON t.id = p.term_id SET o.dirty = 1 WHERE t.term = %s", $row->term )); $carriers = $wpdb->get_results($wpdb->prepare( "SELECT DISTINCT o.object_type, o.object_id FROM `{$objects}` o JOIN `{$postings}` p ON p.object_type = o.object_type AND p.object_id = o.object_id JOIN `{$terms}` t ON t.id = p.term_id WHERE t.term = %s", $row->term )); if (class_exists('AIEO_Recompute_Queue')) { foreach ($carriers as $c) { AIEO_Recompute_Queue::enqueue( AIEO_Recompute_Queue::SURFACE_SEM_REEXTRACT, (int) $c->object_id, (string) $c->object_type ); } } $dirtied = count($carriers); } else { $dirtied = 0; } wp_send_json_success(array( 'status' => $map[$decision], 'message' => $now_approved ? sprintf( __('“%1$s” joins the vocabulary — %2$d objects queued; the background queue re-extracts them within minutes and the next hourly sync ships them.', 'ai-eshop-optimizer'), $row->term, $dirtied ) : __('Decision saved. Rejections are remembered across recomputes.', 'ai-eshop-optimizer'), )); } }