}> keyed by post type */
static function find_orphans(): array
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one grouped count over all post types; no WP API does this, and the result is used once per request
$rows = $wpdb->get_results(
"SELECT post_type, post_status, COUNT(*) AS n FROM {$wpdb->posts} GROUP BY post_type, post_status"
);
$skip = apply_filters('nmrhtbl_exclude', self::INTERNAL);
$out = [];
foreach ((array) $rows as $r) {
if (in_array($r->post_type, $skip, true) || post_type_exists($r->post_type)) continue;
if ($r->post_status === 'auto-draft') continue;
$out[$r->post_type]['statuses'][$r->post_status] = (int) $r->n;
$out[$r->post_type]['total'] = ($out[$r->post_type]['total'] ?? 0) + (int) $r->n;
}
ksort($out);
return $out;
}
/** Orphans found at registration time; after that they are "registered" by this plugin. */
private static ?array $found = null;
static function register_orphans(): void
{
self::$found = self::find_orphans();
foreach (self::$found as $type => $info) {
// register_post_type() accepts 1-20 chars: [a-z0-9_-]
if (strlen($type) > 20 || $type !== sanitize_key($type)) continue;
$label = ucwords(str_replace(['-', '_'], ' ', $type));
register_post_type($type, [
'labels' => [
'name' => $label . ' (orphan)',
'singular_name' => $label,
'all_items' => $label . ' (' . (int) $info['total'] . ')',
],
// Admin only: no front end URL, no search, no archive, no REST (classic editor,
// so old shortcodes are not wrapped in blocks on save).
'public' => false,
'publicly_queryable' => false,
'exclude_from_search' => true,
'has_archive' => false,
'rewrite' => false,
'query_var' => false,
'show_in_rest' => false,
'show_ui' => true,
'show_in_menu' => self::SCREEN, // all types under one main menu entry
'show_in_admin_bar' => false,
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields', 'page-attributes', 'revisions'],
'capability_type' => 'post',
'capabilities' => self::capabilities(),
'map_meta_cap' => true,
]);
}
}
/** Administrators only. */
private static function capabilities(): array
{
$keys = [
'edit_posts', 'edit_others_posts', 'publish_posts', 'read_private_posts', 'create_posts',
'delete_posts', 'delete_private_posts', 'delete_published_posts', 'delete_others_posts',
'edit_private_posts', 'edit_published_posts',
];
return array_fill_keys($keys, 'manage_options');
}
// ── Overview screen ───────────────────────────────────────────────────────
static function admin_menu(): void
{
add_menu_page('NMR HTML Blocks', 'NMR HTML Blocks', 'manage_options', self::SCREEN, [self::class, 'screen'], 'dashicons-archive', 80);
add_submenu_page(self::SCREEN, 'NMR HTML Blocks', 'Overview', 'manage_options', self::SCREEN, [self::class, 'screen']);
}
static function order_menu(): void
{
global $submenu;
if (empty($submenu[self::SCREEN])) return;
$first = [];
$rest = [];
foreach ($submenu[self::SCREEN] as $item) {
if ($item[2] === self::SCREEN) $first[] = $item; else $rest[] = $item;
}
$submenu[self::SCREEN] = array_merge(array_slice($first, 0, 1), $rest);
}
static function action_links(array $links): array
{
array_unshift($links, 'Open ');
return $links;
}
static function screen(): void
{
if (!current_user_can('manage_options')) wp_die(esc_html__('Sorry, you are not allowed to access this page.', 'nmr-html-blocks'));
// Pro (or any add-on) can add tabs: filter the list, then draw the tab on nmrhtbl_render_tab_{slug}.
$tabs = apply_filters('nmrhtbl_tabs', ['impact' => 'Plugin impact', 'orphans' => 'Orphan post types']);
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'impact'; // phpcs:ignore WordPress.Security.NonceVerification
if (!self::pro_active()) $tabs += ['fix' => 'Fix it (Pro)', 'retire' => 'Retire pages (Pro)', 'simulate' => 'Simulate (Pro)']; // teasers only, no functionality
if (!isset($tabs[$tab])) $tab = 'impact';
$base = admin_url('admin.php?page=' . self::SCREEN);
echo 'NMR HTML Blocks ';
foreach ($tabs as $slug => $label) {
echo '' . esc_html($label) . ' ';
}
echo ' ';
if ($tab !== 'orphans') self::lost_data_banner($base . '&tab=orphans');
if ($tab === 'orphans') self::tab_orphans();
elseif ($tab === 'impact') self::tab_impact();
elseif (!self::pro_active() && in_array($tab, ['simulate', 'fix', 'retire'], true)) self::tab_pro_teaser($tab);
else do_action('nmrhtbl_render_tab_' . $tab);
echo '
';
}
// ── Tab: orphan post types ────────────────────────────────────────────────
private static function tab_orphans(): void
{
$orphans = self::$found ?? self::find_orphans();
echo 'Post types that exist in the database but are not registered by the active theme or plugins. All of them are listed under this menu entry, with no public URL. Review and edit them there, export them, or delete them.
';
if (!$orphans) {
echo 'No orphan post types found.
';
return;
}
$mostly_demo = [];
echo 'Post type Likely source Posts By status Placeholder text Admin Export ';
foreach ($orphans as $type => $info) {
$parts = [];
foreach ($info['statuses'] as $status => $n) $parts[] = esc_html($status) . ': ' . (int) $n;
if (strlen($type) > 20 || $type !== sanitize_key($type)) {
$link = 'cannot be registered (name longer than 20 characters or invalid) ';
} else {
$link = 'Open list ';
}
$demo = self::count_demo($type);
if ($demo > 0 && $demo >= $info['total'] / 2) $mostly_demo[] = $type;
$demo_cell = $demo ? (int) $demo . ' of ' . (int) $info['total'] : '–';
echo '' . esc_html($type) . '' . (self::source_of($type) !== '' ? esc_html(self::source_of($type)) : '–') . ' ' . (int) $info['total'] . ' ' . wp_kses_post(implode(', ', $parts)) . ' ' . wp_kses_post($demo_cell) . ' ' . wp_kses_post($link) . ' '
. 'CSV · JSON ';
}
echo '
';
if ($mostly_demo) {
echo 'Looks like theme demo content: '
. esc_html(implode(', ', $mostly_demo))
. ' (half or more of the posts contain placeholder text such as “lorem ipsum”). This is a guess from the text only. Open the list, check a few posts, export a copy, and delete them if they are not real content.
';
}
self::shortcodes_section($orphans);
echo 'CSV has one row per post (no content). JSON includes the content and the custom fields. Custom fields appear in the Custom Fields box (enable it in Screen Options). Old page builder shortcodes show as plain text in the editor. Back up the database before bulk deleting.
';
}
// ── Plugins screen: warn before deactivating a plugin that live content uses ──
const OPT_CONFIRM = 'nmrhtbl_confirm';
/** @var array|null plugin file => usage */
private static ?array $usage = null;
/** Above this many published items a scan is not started automatically on the Plugins screen. */
const AUTO_SCAN_LIMIT = 20000;
static function plugins_screen(): void
{
if (!current_user_can('activate_plugins')) return;
self::$usage = self::plugin_usage();
if (self::$usage === null) {
add_action('admin_notices', static function () {
echo 'NMR HTML Blocks: this site is large, so usage warnings are off until you run the scan once .
';
});
return;
}
add_filter('plugin_row_meta', [self::class, 'row_meta'], 10, 2);
add_filter('plugin_action_links', [self::class, 'deactivate_link'], 99, 2);
add_action('admin_enqueue_scripts', [self::class, 'plugins_script']);
}
/** @return array|null null = not computed (large site, no cached scan) */
private static function plugin_usage(): ?array
{
global $wpdb;
$scan = get_transient(self::LIVE_CACHE);
if (!is_array($scan) || !isset($scan['sc'], $scan['bl'])) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plain count, run only when the Plugins screen opens
$n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status IN ('publish','private')");
if ($n > self::AUTO_SCAN_LIMIT) return null;
$scan = self::scan_live();
}
[$groups] = self::build_impact($scan);
if (!function_exists('get_plugins')) require_once ABSPATH . 'wp-admin/includes/plugin.php';
$by_slug = [];
foreach (get_plugins() as $file => $data) {
$by_slug[strpos($file, '/') === false ? $file : strstr($file, '/', true)] = [$file, $data['Name']];
}
$out = [];
foreach ($groups as $g) {
if ($g['p']['kind'] !== 'plugin' || !isset($by_slug[$g['p']['slug']])) continue;
[$file, $name] = $by_slug[$g['p']['slug']];
$ids = array_values(array_unique($g['ids']));
$public = array_values(array_filter($ids, [self::class, 'is_public']));
$items = [];
foreach ($g['items'] as $it) $items[] = $it['label'];
$out[$file] = [
'name' => $name,
'items' => $items,
'ids' => $ids,
'pub' => count($public),
'titles' => array_map(static fn($id) => wp_strip_all_tags(get_the_title($id)) ?: '#' . $id, array_slice($public, 0, 5)),
];
}
return $out;
}
static function row_meta(array $meta, string $file): array
{
$u = self::$usage[$file] ?? null;
if (!$u) return $meta;
$shown = array_slice($u['items'], 0, 4);
$meta[] = 'Used in ' . count($u['ids']) . ' posts (' . (int) $u['pub'] . ' public): '
. esc_html(implode(' ', $shown)) . (count($u['items']) > 4 ? ' …' : '') . ' '
. 'Show ' . self::post_links($u['ids']) . ' ';
return $meta;
}
private static function confirm_text(string $file): string
{
$u = self::$usage[$file];
$text = sprintf("Deactivating %s will break %d post(s), %d of them public, that use its shortcodes or blocks:\n", $u['name'], count($u['ids']), $u['pub']);
foreach ($u['titles'] as $t) $text .= "\n• " . $t;
if ($u['pub'] > count($u['titles'])) $text .= "\n• and " . ($u['pub'] - count($u['titles'])) . ' more';
return $text . "\n\nBased on shortcodes and dynamic blocks in your content only. Continue?";
}
private static function wants_confirm(string $file): bool
{
return get_option(self::OPT_CONFIRM, '1') === '1' && !empty(self::$usage[$file]['pub']);
}
static function deactivate_link(array $actions, string $file): array
{
if (isset($actions['deactivate']) && self::wants_confirm($file)) {
$actions['deactivate'] = preg_replace('/^ true])), ['wp_block'])), ['attachment']));
$ph = implode(',', array_fill(0, count($types), '%s'));
$sc = [];
$bl = [];
$count = 0;
$last = 0;
$cap = 5000;
do {
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ph is a list of %s placeholders; scanning content in batches cannot use a WP API
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts} WHERE post_type IN ($ph) AND post_status IN ('publish','private') AND ID > %d ORDER BY ID LIMIT 200",
array_merge($types, [$last])
));
// phpcs:enable
foreach ((array) $rows as $r) {
$last = (int) $r->ID;
$count++;
$txt = (string) $r->post_content;
if (preg_match_all('/(? $n) {
$sc[$tag]['n'] = ($sc[$tag]['n'] ?? 0) + $n;
if (isset($closers[$tag]) || isset($withat[$tag])) $sc[$tag]['strong'] = ($sc[$tag]['strong'] ?? 0) + $n;
if (count($sc[$tag]['ids'] ?? []) < $cap) $sc[$tag]['ids'][] = $last;
}
}
if (preg_match_all('/