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;
?>