true])), ['wp_template', 'wp_template_part', 'wp_block'] ))); } function scan_limit_error() { return new \WP_Error('fqbp_instance_scan_limit', __('Saved content exceeds the placement inventory scan limits (2 MB, 64 levels or 10,000 blocks). Previous inventory data has been retained.', 'accomplice-query-loop-filters')); } /** Check tokens without constructing a potentially enormous nested block tree. */ function parse_source_blocks($content) { $content = (string) $content; if (strlen($content) > MAX_CONTENT_BYTES) return scan_limit_error(); $parser = new \WP_Block_Parser(); $parser->document = $content; $parser->offset = 0; $nodes = 0; $depth = 0; while (true) { [$type, , , $offset, $length] = $parser->next_token(); if ($type === 'no-more-tokens') break; if ($type === 'block-opener' || $type === 'void-block') { if (++$nodes > MAX_BLOCK_NODES || $depth + 1 > MAX_BLOCK_DEPTH) return scan_limit_error(); } if ($type === 'block-opener') $depth++; if ($type === 'block-closer') $depth = max(0, $depth - 1); $parser->offset = $offset + $length; } // Keep the normal parse_blocks filter contract after the resource preflight. return parse_blocks($content); } /** * Extract saved definitions and reference edges, never duplicate referenced content. * Query context resets at every core/query, including nested plain queries. * Returns WP_Error on resource limits; partial inventories must never replace old data. */ function summarize($content) { $blocks = parse_source_blocks($content); if (is_wp_error($blocks)) return $blocks; $instances = []; $references = []; $nodes = 0; $walk = function ($blocks, $owner = null, $path = [], $owned_headers = [], $depth = 1) use (&$walk, &$instances, &$references, &$nodes) { foreach ($blocks as $index => $block) { if ($depth > MAX_BLOCK_DEPTH || ++$nodes > MAX_BLOCK_NODES) return false; $name = $block['blockName'] ?? ''; $attrs = $block['attrs'] ?? []; $position = array_merge($path, [$index]); $child_owner = $owner; $child_headers = $owned_headers; if ($name === 'core/query') { $child_owner = $block; $child_headers = []; foreach (Integration\query_header_blocks($block) as $header) { $child_headers[implode('.', array_merge($position, $header['path']))] = true; } } if ($name === 'filter-query-block-pro/filter-header') { $attrs = \Fqbpro\Plugin\Blocks\FilterHeader\resolve_header_attributes($block); $is_child = isset($owned_headers[implode('.', $position)]) && ($owner['blockName'] ?? '') === 'core/query'; $query = $is_child ? ($owner['attrs'] ?? []) : []; $instances[] = [ 'query_id' => absint($query['queryId'] ?? 0), 'post_type' => is_string($query['query']['postType'] ?? null) ? sanitize_key($query['query']['postType']) : 'post', 'filter_count' => (int) ($attrs['nativeVersion'] ?? 0) === 1 ? count(array_filter($attrs['nativeControls'] ?? [], static fn($control) => in_array($control['kind'] ?? '', ['choice', 'date', 'number'], true))) : count(is_array($attrs['filters'] ?? null) ? $attrs['filters'] : []), 'path' => implode('.', $position), 'has_query_parent' => $is_child, ]; continue; } if (function_exists(__NAMESPACE__ . '\\summarize_legacy_placement')) { $legacy = summarize_legacy_placement($block, $position); if ($legacy) $instances[] = $legacy; } if ($name === 'core/block' && !empty($attrs['ref'])) { $references[] = 'post:' . absint($attrs['ref']); } if ($name === 'core/template-part' && !empty($attrs['slug'])) { $references[] = 'template:' . sanitize_key($attrs['theme'] ?? get_stylesheet()) . '//' . sanitize_title($attrs['slug']); } if (!$walk($block['innerBlocks'] ?? [], $child_owner, $position, $child_headers, $depth + 1)) return false; } return true; }; if (!$walk($blocks)) return scan_limit_error(); return ['instances' => $instances, 'references' => array_values(array_unique($references))]; } /** Hash the saved inventory, including references that may have been repaired/deleted. */ function metadata_signature($instances, $count, $references) { $references = array_values(array_unique((array) $references)); sort($references, SORT_STRING); return hash('sha256', (string) wp_json_encode([$instances, (string) $count, $references])); } /** Preserve the legacy placement's existing automatic tracking on cached saves. */ function track_legacy_instances($instances) { foreach (is_array($instances) ? $instances : [] as $instance) { if (!empty($instance['legacy']) && function_exists('Fqbpro\\Plugin\\Catalog\\Service\\track_referenced_post_type')) { Service\track_referenced_post_type($instance['post_type']); } } } /** Update only changed source content; reverse references are resolved on read. */ function sync_post($post_id, $post = null) { $post = $post instanceof \WP_Post ? $post : get_post($post_id); if (!$post || !in_array($post->post_type, source_types(), true)) { return; } $cleared = in_array($post->post_status, ['trash', 'auto-draft', 'inherit'], true); $hash = hash_init('sha256'); hash_update($hash, implode("\0", [ SUMMARY_SCHEMA_VERSION, defined('FQBPRO_EDITION') ? FQBPRO_EDITION : 'pro', get_stylesheet(), $post->post_type, $cleared ? 'cleared' : 'active', ]) . "\0"); // Hash the body separately rather than duplicating a potentially oversized source. hash_update($hash, $post->post_content); $fingerprint = hash_final($hash); $previous = get_post_meta($post_id, FINGERPRINT_META, true); $instances = get_post_meta($post_id, SUMMARY_META, true); $count = get_post_meta($post_id, COUNT_META, true); $references = get_post_meta($post_id, REFERENCE_META); if (is_array($previous) && ($previous['content'] ?? '') === $fingerprint && ($previous['metadata'] ?? '') === metadata_signature($instances, $count, $references) && !metadata_exists('post', $post_id, ERROR_META)) { track_legacy_instances($instances); return; } $summary = $cleared ? ['instances' => [], 'references' => []] : summarize($post->post_content); if (is_wp_error($summary)) { delete_post_meta($post_id, FINGERPRINT_META); update_post_meta($post_id, ERROR_META, $summary->get_error_message()); return $summary; } delete_post_meta($post_id, ERROR_META); track_legacy_instances($summary['instances']); if ($summary['instances']) { update_post_meta($post_id, SUMMARY_META, $summary['instances']); update_post_meta($post_id, COUNT_META, count($summary['instances'])); } else { delete_post_meta($post_id, SUMMARY_META); delete_post_meta($post_id, COUNT_META); } $current = get_post_meta($post_id, REFERENCE_META); foreach (array_diff($current, $summary['references']) as $removed) { delete_post_meta($post_id, REFERENCE_META, $removed); } foreach (array_diff($summary['references'], $current) as $added) { add_post_meta($post_id, REFERENCE_META, $added); } $expected = metadata_signature( $summary['instances'] ?: '', $summary['instances'] ? count($summary['instances']) : '', $summary['references'] ); $stored = metadata_signature( get_post_meta($post_id, SUMMARY_META, true), get_post_meta($post_id, COUNT_META, true), get_post_meta($post_id, REFERENCE_META) ); // A failed metadata write must not turn a partial inventory into a cache hit. if ($expected === $stored && !metadata_exists('post', $post_id, ERROR_META)) { update_post_meta($post_id, FINGERPRINT_META, ['content' => $fingerprint, 'metadata' => $expected]); } else { delete_post_meta($post_id, FINGERPRINT_META); } } /** Free shares this inventory without Pro's maintenance module. */ function workers_paused() { return function_exists('Fqbpro\\Plugin\\Catalog\\Maintenance\\is_paused') ? Maintenance\is_paused() : (bool) get_option(STOP_OPTION, false); } function manual_recovery() { return function_exists('Fqbpro\\Plugin\\Catalog\\Maintenance\\is_manual') && Maintenance\is_manual(); } function activate($network_wide = false) { if ($network_wide && is_multisite()) { foreach (get_sites(['fields' => 'ids', 'number' => 0]) as $site) { switch_to_blog((int) $site); try { activate(false); } finally { restore_current_blog(); } } return; } delete_option(STOP_OPTION); delete_option(STATE_OPTION); } /** Uncached barrier is used only by active workers, never by ordinary page reads. */ function deactivated_during_batch() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- The worker completion barrier must bypass caches to observe another request deactivating the plugin. return (bool) $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", STOP_OPTION)); } function schedule() { if (workers_paused() || manual_recovery()) return; $version = FQBPRO_PLUGIN_VERSION . ':' . SUMMARY_SCHEMA_VERSION; if (get_option(VERSION_OPTION) !== $version) { delete_option(STATE_OPTION); update_option(VERSION_OPTION, $version, false); wp_clear_scheduled_hook(BATCH_HOOK); wp_schedule_single_event(time() + MINUTE_IN_SECONDS, BATCH_HOOK); } if (!wp_next_scheduled(CRON_HOOK)) { wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', CRON_HOOK); } $state = get_status(); if (!$state['completed_at'] && !wp_next_scheduled(BATCH_HOOK)) { wp_schedule_single_event(time() + MINUTE_IN_SECONDS, BATCH_HOOK); } } function get_status() { $state = get_option(STATE_OPTION, []); return array_replace(['cursor' => 0, 'ceiling' => 0, 'running' => false, 'completed_at' => '', 'last_error' => ''], is_array($state) ? $state : []); } /** Bootstrap/repair the small index in resumable batches, outside page reads. */ function lock_name() { global $wpdb; return substr('fqbpro_instances_' . md5(DB_NAME . ':' . $wpdb->prefix), 0, 64); } function scan_batch($limit = BATCH_SIZE, $manual = false) { if (workers_paused() || (manual_recovery() && !$manual)) return 0; global $wpdb; $lock = lock_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Advisory locks must execute against the current database connection, never an object cache. if ((string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock)) !== '1') { return 0; } $state = get_status(); $count = 0; try { if (workers_paused()) return 0; $limit = max(1, min(BATCH_SIZE, (int) $limit)); if (!$state['running']) { $state['cursor'] = 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Each repair scan needs a fresh upper cursor bound so newly saved sources are included. $state['ceiling'] = (int) $wpdb->get_var($wpdb->prepare('SELECT MAX(ID) FROM %i', $wpdb->posts)); $state['running'] = true; } $types = source_types(); $placeholders = implode(',', array_fill(0, count($types), '%s')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The generated IN placeholders match the prepared argument array; the repair cursor reads at most BATCH_SIZE current rows; cached batches could retain stale summaries after edits. $posts = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only generated %s placeholders are interpolated; the table and every value are supplied to prepare below. "SELECT * FROM %i WHERE post_type IN ({$placeholders}) AND ID > %d AND ID <= %d ORDER BY ID LIMIT %d", array_merge([$wpdb->posts], $types, [$state['cursor'], $state['ceiling'], $limit]) )); if ($wpdb->last_error) { throw new \RuntimeException($wpdb->last_error); } foreach ($posts as $post) { clean_post_cache($post->ID); sync_post((int) $post->ID, new \WP_Post($post)); $state['cursor'] = (int) $post->ID; $count++; } if ($count < $limit) { $state['running'] = false; $state['completed_at'] = gmdate('c'); wp_clear_scheduled_hook(BATCH_HOOK); } elseif (!manual_recovery() && !workers_paused() && !wp_next_scheduled(BATCH_HOOK)) { wp_schedule_single_event(time() + MINUTE_IN_SECONDS, BATCH_HOOK); } $state['last_error'] = ''; } catch (\Throwable $error) { $state['last_error'] = substr(sanitize_text_field($error->getMessage()), 0, 1000); } finally { if (deactivated_during_batch()) { delete_option(STATE_OPTION); wp_clear_scheduled_hook(CRON_HOOK); wp_clear_scheduled_hook(BATCH_HOOK); } else update_option(STATE_OPTION, $state, false); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the advisory lock on this database connection; this operation cannot be cached. $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); if (function_exists('Fqbpro\\Plugin\\Catalog\\Maintenance\\complete_deactivation')) Maintenance\complete_deactivation(); } return $count; } /** Return one page of saved definitions without parsing all saved content. */ function get_sources($page = 1, $search = '') { $query = new \WP_Query([ 'post_type' => source_types(), 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], 'posts_per_page' => 20, 'paged' => max(1, (int) $page), 's' => sanitize_text_field($search), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The admin inventory reads its saved summary index, with 20 results per page, instead of parsing all post content. 'meta_key' => COUNT_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Only indexed summaries with filter placements belong in this paginated inventory. 'meta_value' => 0, 'meta_compare' => '>', 'meta_type' => 'NUMERIC', 'orderby' => 'ID', 'order' => 'DESC', ]); return ['posts' => $query->posts, 'pages' => (int) $query->max_num_pages, 'total' => (int) $query->found_posts]; } /** * Find direct and transitive placements of a definition, with cycle detection. * Returns a bounded preview and a visible truncation flag for large graphs. */ function get_usages($key, $limit = 100) { $pending = [$key]; $visited = []; $usages = []; $truncated = false; while ($pending) { $current = array_shift($pending); if (isset($visited[$current])) { continue; } $visited[$current] = true; $references = get_posts([ 'post_type' => source_types(), 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], 'posts_per_page' => $limit + 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The saved reverse-reference index is needed for this bounded admin usage preview. 'meta_key' => REFERENCE_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Match one exact reference edge; stop traversal at the preview limit. 'meta_value' => $current, 'orderby' => 'ID', 'order' => 'ASC', ]); foreach ($references as $post) { if ('post:' . $post->ID === $key) { continue; } if (isset($usages[$post->ID])) { continue; } if (count($usages) >= $limit) { $truncated = true; break 2; } $usages[$post->ID] = $post; $pending[] = 'post:' . $post->ID; if ($post->post_type === 'wp_template_part') { $themes = wp_get_object_terms($post->ID, 'wp_theme', ['fields' => 'names']); foreach (is_wp_error($themes) ? [] : $themes as $theme) { $pending[] = 'template:' . $theme . '//' . $post->post_name; } } } } return ['posts' => array_values($usages), 'truncated' => $truncated]; } function render_page() { if (!current_user_can('manage_options')) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination in the authorized admin inventory; no state is changed. $page = isset($_GET['instance_page']) && is_string($_GET['instance_page']) ? max(1, absint(wp_unslash($_GET['instance_page']))) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only search in the authorized admin inventory; no state is changed. $search = isset($_GET['instance_search']) && is_string($_GET['instance_search']) ? sanitize_text_field(wp_unslash($_GET['instance_search'])) : ''; $sources = get_sources($page, $search); $state = get_status(); ?>

source_types(), 'post_status' => 'any', 'meta_key' => ERROR_META, 'posts_per_page' => 1, 'fields' => 'ids', 'orderby' => 'none']); if ($skipped) : ?>


post_type . ' · ' . $post->post_status); ?>
ID, SUMMARY_META, true) as $instance) { $id = (int) $instance['query_id']; printf('

%s

', esc_html(sprintf( /* translators: 1: Query ID, 2: post type, 3: filter count. */ __('Query %1$d · %2$s · %3$d filters', 'accomplice-query-loop-filters'), $id, $instance['post_type'], $instance['filter_count'] ))); if (!$instance['has_query_parent'] || !$id || isset($seen[$id])) { echo '

' . esc_html__('Review this block: missing Query parent/ID or duplicate Query ID.', 'accomplice-query-loop-filters') . '

'; } $edit_url = get_edit_post_link($post->ID, 'raw'); $is_legacy = !empty($instance['legacy']); echo '' . esc_html($is_legacy ? __('Version 3', 'accomplice-query-loop-filters') : __('Version 4', 'accomplice-query-loop-filters')) . ' '; if ($edit_url && !$is_legacy && $id && $instance['has_query_parent']) { printf('%s', esc_url(add_query_arg('fqb-query-id', $id, $edit_url)), esc_html__('Edit filter settings', 'accomplice-query-loop-filters')); } $seen[$id] = true; } ?>
ID; if ($post->post_type === 'wp_template_part') { $themes = wp_get_object_terms($post->ID, 'wp_theme', ['fields' => 'names']); $key = 'template:' . (!is_wp_error($themes) && $themes ? $themes[0] : get_stylesheet()) . '//' . $post->post_name; } $usages = get_usages($key); foreach ($usages['posts'] as $usage) { echo '

'; render_source_link($usage); echo '

'; } if ($usages['truncated']) { echo '

' . esc_html__('Showing the first 100 referencing sources.', 'accomplice-query-loop-filters') . '

'; } elseif (!$usages['posts']) { echo esc_html__('Defined in this source.', 'accomplice-query-loop-filters'); } ?>
1) : ?>
ID, 'raw'); if ($url) { printf('%s', esc_url($url), esc_html($title)); } else { echo esc_html($title); } } function deactivate($network_wide = false) { if ($network_wide && is_multisite()) { foreach (get_sites(['fields' => 'ids', 'number' => 0]) as $site_id) { switch_to_blog((int) $site_id); try { deactivate(false); } finally { restore_current_blog(); } } return; } update_option(STOP_OPTION, true, false); delete_option(STATE_OPTION); wp_clear_scheduled_hook(CRON_HOOK); wp_clear_scheduled_hook(BATCH_HOOK); } /** Inspect recorded per-source failures across every language, without parsing content. */ function rejected_source() { global $wpdb; $types = source_types(); $placeholders = implode(',', array_fill(0, count($types), '%s')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Live diagnostics must bypass cache; the generated list contains only %s placeholders and every value is prepared. return (int) $wpdb->get_var($wpdb->prepare("SELECT p.ID FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id=p.ID WHERE m.meta_key=%s AND p.post_type IN ({$placeholders}) ORDER BY p.ID LIMIT 1", array_merge([ERROR_META], $types))); }