Files
wp-agentic-writer/includes/class-sidebar-helpers.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

1404 lines
42 KiB
PHP

<?php
/**
* Sidebar Helper Methods
*
* Pure utility functions for JSON extraction, block manipulation, content parsing,
* plan normalization, and SEO pattern detection. All methods are static — no instance
* state, no $this, no REST context. Each method receives all data as parameters.
*
* @package WP_Agentic_Writer
* @since 0.3.0
*/
/**
* Class WP_Agentic_Writer_Sidebar_Helpers
*
* Utility class containing pure helper functions extracted from
* WP_Agentic_Writer_Gutenberg_Sidebar.
*
* @since 0.3.0
*/
class WP_Agentic_Writer_Sidebar_Helpers
{
// =========================================================================
// JSON Extraction Methods
// =========================================================================
/**
* Extract JSON from model response text with multiple fallback strategies.
*
* @since 0.2.2
* @param string $string Raw model response.
* @return array|null Decoded JSON array or null on failure.
*/
public static function extract_json($string)
{
$string = trim((string) $string);
if ("" === $string) {
return null;
}
// Method 1: JSON wrapped in markdown code block.
if (
preg_match_all("/```(?:json)?\s*([\s\S]*?)```/i", $string, $matches)
) {
foreach ($matches[1] as $candidate) {
$json = json_decode(trim($candidate), true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json;
}
}
}
// Method 2: Decode the whole string.
$json = json_decode($string, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json;
}
// Method 3: Extract balanced JSON object/array candidates.
$candidates = array_merge(
self::extract_balanced_json_candidates($string, "{", "}"),
self::extract_balanced_json_candidates($string, "[", "]"),
);
foreach ($candidates as $candidate) {
$json = json_decode($candidate, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json;
}
}
return null;
}
/**
* Extract balanced JSON object/array candidates from model text.
*
* @since 0.2.2
* @param string $string Source text.
* @param string $open_char Opening character ({ or [).
* @param string $close_char Closing character (} or ]).
* @return array List of candidate JSON strings.
*/
public static function extract_balanced_json_candidates(
$string,
$open_char = "{",
$close_char = "}",
) {
$candidates = [];
$length = strlen($string);
$depth = 0;
$start = null;
$in_string = false;
$escaped = false;
for ($i = 0; $i < $length; $i++) {
$char = $string[$i];
if ($in_string) {
if ($escaped) {
$escaped = false;
} elseif ("\\" === $char) {
$escaped = true;
} elseif ('"' === $char) {
$in_string = false;
}
continue;
}
if ('"' === $char) {
$in_string = true;
continue;
}
if ($open_char === $char) {
if (0 === $depth) {
$start = $i;
}
$depth++;
} elseif ($close_char === $char && $depth > 0) {
$depth--;
if (0 === $depth && null !== $start) {
$candidates[] = substr($string, $start, $i - $start + 1);
$start = null;
}
}
}
usort($candidates, function ($a, $b) {
return strlen($b) - strlen($a);
});
return $candidates;
}
/**
* Extract JSON from a streaming chunk.
*
* @since 0.3.0
* @param string $chunk Raw chunk from stream.
* @return array|null Decoded JSON or null.
*/
public static function extract_json_from_stream_chunk($chunk)
{
return self::extract_json($chunk);
}
// =========================================================================
// Plan Extraction & Normalization Methods
// =========================================================================
/**
* Extract plan from model response using JSON or markdown fallback.
*
* @since 0.2.2
* @param string $content Raw model response.
* @param string $fallback_title Fallback title/topic.
* @param array $previous_plan Previous plan for context.
* @return array|null Normalized plan or null.
*/
public static function extract_plan_from_response(
$content,
$fallback_title = "",
$previous_plan = [],
) {
$json = self::extract_json($content);
$normalized_json_plan = self::normalize_extracted_plan_json(
$json,
$fallback_title,
);
if (!empty($normalized_json_plan["sections"])) {
return $normalized_json_plan;
}
$markdown_plan = self::build_plan_from_markdown_outline(
$content,
$fallback_title,
$previous_plan,
);
if (!empty($markdown_plan["sections"])) {
return $markdown_plan;
}
return null;
}
/**
* Normalize common model outline JSON variants into the required plan schema.
*
* @since 0.2.3
* @param mixed $json Decoded model JSON.
* @param string $fallback_title Fallback title/topic.
* @return array|null
*/
public static function normalize_extracted_plan_json($json, $fallback_title = "")
{
if (!is_array($json)) {
return null;
}
// Some models return the sections array directly.
if (array_is_list($json)) {
$json = [
"title" => $fallback_title,
"sections" => $json,
];
}
// Some models nest the outline under a descriptive top-level key.
foreach (
["plan", "outline", "article_plan", "articlePlan", "data"]
as $key
) {
if (
empty($json["sections"]) &&
isset($json[$key]) &&
is_array($json[$key])
) {
$nested = self::normalize_extracted_plan_json(
$json[$key],
$fallback_title,
);
if (!empty($nested["sections"])) {
return $nested;
}
}
}
$section_keys = [
"sections",
"outline",
"items",
"chapters",
"headings",
"bagian",
];
$sections = [];
foreach ($section_keys as $key) {
if (!empty($json[$key]) && is_array($json[$key])) {
$sections = $json[$key];
break;
}
}
if (empty($sections)) {
return null;
}
$title =
$json["title"] ??
($json["judul"] ?? ($json["headline"] ?? $fallback_title));
$title = self::clean_outline_heading($title);
if ("" === $title) {
$title = __("Article Outline", "wp-agentic-writer");
}
$normalized_sections = [];
foreach ($sections as $index => $section) {
if (is_string($section)) {
$section = ["heading" => $section];
}
if (!is_array($section)) {
continue;
}
$heading =
$section["heading"] ??
($section["title"] ??
($section["judul"] ??
($section["name"] ??
($section["h2"] ??
sprintf("Section %d", $index + 1)))));
$heading = self::clean_outline_heading($heading);
if ("" === $heading) {
continue;
}
$content_items =
$section["content"] ??
($section["description"] ??
($section["summary"] ??
($section["points"] ?? ($section["bullets"] ?? []))));
$content = self::normalize_plan_section_content_items(
$content_items,
);
if (empty($content)) {
$content[] = [
"type" => "paragraph",
"content" => $heading,
];
}
$normalized_sections[] = [
"id" => sanitize_key($section["id"] ?? ""),
"status" => sanitize_key($section["status"] ?? "pending"),
"type" => sanitize_key($section["type"] ?? "section"),
"heading" => $heading,
"content" => $content,
];
}
if (empty($normalized_sections)) {
return null;
}
$meta =
isset($json["meta"]) && is_array($json["meta"])
? $json["meta"]
: [];
return [
"title" => $title,
"meta" => wp_parse_args($meta, [
"reading_time" => "5 min",
"difficulty" => "intermediate",
"cost_estimate" => 0.7,
]),
"sections" => $normalized_sections,
];
}
/**
* Normalize varied model section content into plan content items.
*
* @since 0.2.3
* @param mixed $items Section content candidate.
* @return array
*/
public static function normalize_plan_section_content_items($items)
{
if (is_string($items)) {
$items = [$items];
}
if (!is_array($items)) {
return [];
}
$normalized = [];
foreach ($items as $item) {
if (is_string($item)) {
$text = trim(wp_strip_all_tags($item));
if ("" !== $text) {
$normalized[] = [
"type" => "paragraph",
"content" => $text,
];
}
continue;
}
if (!is_array($item)) {
continue;
}
$text =
$item["content"] ??
($item["text"] ??
($item["description"] ?? ($item["point"] ?? "")));
$text = trim(wp_strip_all_tags((string) $text));
if ("" === $text) {
continue;
}
$normalized[] = [
"type" => sanitize_key($item["type"] ?? "paragraph"),
"content" => $text,
];
}
return $normalized;
}
/**
* Build a plan schema from markdown/numbered outline output.
*
* @since 0.2.2
* @param string $content Markdown content.
* @param string $fallback_title Fallback title.
* @param array $previous_plan Previous plan for context.
* @return array|null
*/
public static function build_plan_from_markdown_outline(
$content,
$fallback_title = "",
$previous_plan = [],
) {
$lines = preg_split('/\r\n|\r|\n/', (string) $content);
if (!is_array($lines)) {
return null;
}
$title = "";
$sections = [];
$current = null;
foreach ($lines as $raw_line) {
$line = trim(wp_strip_all_tags((string) $raw_line));
if ("" === $line) {
continue;
}
$line = preg_replace(
'/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/',
'$1',
$line,
);
$heading = "";
if (preg_match('/^#{1,2}\s+(.+)$/', $line, $matches)) {
$text = self::clean_outline_heading($matches[1]);
if ("" === $title) {
$title = $text;
continue;
}
$heading = $text;
} elseif (preg_match('/^\d+[\.)]\s+(.+)$/', $line, $matches)) {
$heading = self::clean_outline_heading($matches[1]);
} elseif (
preg_match(
'/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i',
$line,
$matches,
)
) {
$heading = self::clean_outline_heading($matches[1]);
} elseif (
"" === $title &&
!preg_match(
"/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i",
$line,
)
) {
$title = self::clean_outline_heading($line);
continue;
}
if (
"" !== $heading &&
!preg_match(
"/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i",
$heading,
)
) {
if (null !== $current) {
$sections[] = $current;
}
$current = [
"id" => wp_generate_uuid4(),
"status" => "pending",
"type" => "section",
"heading" => $heading,
"content" => [],
];
continue;
}
if (null !== $current) {
$detail = preg_replace("/^[-*]\s+/", "", $line);
if (
"" !== $detail &&
!preg_match(
"/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i",
$detail,
)
) {
$current["content"][] = [
"type" => "paragraph",
"content" => $detail,
];
}
}
}
if (null !== $current) {
$sections[] = $current;
}
if (empty($sections)) {
return null;
}
if ("" === $title) {
$title = self::clean_outline_heading($fallback_title);
}
if ("" === $title && !empty($previous_plan["title"])) {
$title = (string) $previous_plan["title"];
}
if ("" === $title) {
$title = __("Article Outline", "wp-agentic-writer");
}
return [
"title" => $title,
"meta" => [
"reading_time" => "5 min",
"difficulty" => "intermediate",
"cost_estimate" => 0.7,
],
"sections" => $sections,
];
}
/**
* Clean markdown decoration from an outline heading.
*
* @since 0.2.2
* @param string $heading Heading text.
* @return string
*/
public static function clean_outline_heading($heading)
{
$heading = trim((string) $heading);
$heading = preg_replace('/^\s*["\'`]+|["\'`]+\s*$/', "", $heading);
$heading = preg_replace("/\*\*(.*?)\*\*/", '$1', $heading);
$heading = preg_replace("/\s+/", " ", $heading);
return trim($heading);
}
// =========================================================================
// Block Manipulation Methods
// =========================================================================
/**
* Find block by client ID in parsed blocks array (recursive).
*
* @since 0.1.0
* @param array $blocks Parsed blocks array.
* @param string $client_id Block client ID to find.
* @return array|null Block data or null if not found.
*/
public static function find_block_by_client_id($blocks, $client_id)
{
foreach ($blocks as $block) {
if (
isset($block["attrs"]["clientId"]) &&
$block["attrs"]["clientId"] === $client_id
) {
return $block;
}
// Check inner blocks
if (
isset($block["innerBlocks"]) &&
is_array($block["innerBlocks"])
) {
$found = self::find_block_by_client_id(
$block["innerBlocks"],
$client_id,
);
if ($found) {
return $found;
}
}
}
return null;
}
/**
* Find block index in parsed blocks array.
*
* @since 0.1.0
* @param array $blocks Parsed blocks array.
* @param string $client_id Block client ID to find.
* @return int Block index or -1 if not found.
*/
public static function find_block_index($blocks, $client_id)
{
foreach ($blocks as $index => $block) {
if (
isset($block["attrs"]["clientId"]) &&
$block["attrs"]["clientId"] === $client_id
) {
return $index;
}
}
return -1;
}
/**
* Extract content from block data.
*
* @since 0.1.0
* @param array $block Block data.
* @return string Block content.
*/
public static function extract_block_content($block)
{
if (isset($block["attrs"]["content"])) {
return $block["attrs"]["content"];
}
if (isset($block["innerHTML"])) {
return wp_strip_all_tags($block["innerHTML"]);
}
return "";
}
/**
* Extract heading from block.
*
* @since 0.1.0
* @param array $block Block data.
* @return array|null Heading data or null if not a heading.
*/
public static function extract_heading_from_block($block)
{
if (
"core/heading" === $block["blockName"] &&
isset($block["attrs"]["content"])
) {
return [
"heading" => $block["attrs"]["content"],
];
}
return null;
}
/**
* Extract content from block attributes based on block type.
*
* @since 0.1.0
* @param string $block_type Block type (e.g., 'core/paragraph').
* @param array $attrs Block attributes.
* @return string Extracted content.
*/
public static function extract_block_content_from_attrs($block_type, $attrs)
{
switch ($block_type) {
case "core/paragraph":
return isset($attrs["content"]) ? $attrs["content"] : "";
case "core/heading":
return isset($attrs["content"]) ? $attrs["content"] : "";
case "core/list":
if (isset($attrs["values"]) && is_array($attrs["values"])) {
return implode("\n", $attrs["values"]);
}
return "";
case "core/code":
return isset($attrs["content"]) ? $attrs["content"] : "";
case "core/image":
if (isset($attrs["url"]) && isset($attrs["alt"])) {
return "![" . $attrs["alt"] . "](" . $attrs["url"] . ")";
}
return "";
default:
if (isset($attrs["content"])) {
return $attrs["content"];
}
if (isset($attrs["value"])) {
return $attrs["value"];
}
return "";
}
}
/**
* Serialize block object for consistent handling.
*
* @since 0.1.0
* @param array $block Block data.
* @return array Serialized block with clientId.
*/
public static function serialize_block($block)
{
if (!isset($block["attrs"]["clientId"])) {
$block["attrs"]["clientId"] = isset($block["clientId"])
? $block["clientId"]
: uniqid();
}
return $block;
}
/**
* Get blocks from post content.
*
* @since 0.1.0
* @param int|string $post_id_or_content Post ID or post content string.
* @return array Array of block objects.
*/
public static function select_blocks($post_id_or_content)
{
if (is_numeric($post_id_or_content)) {
$post_id = (int) $post_id_or_content;
$post = get_post($post_id);
if (!$post) {
return [];
}
$blocks = parse_blocks($post->post_content);
} else {
$blocks = parse_blocks((string) $post_id_or_content);
}
return array_filter($blocks, function ($block) {
return !empty($block["blockName"]);
});
}
// =========================================================================
// Refinement Methods
// =========================================================================
/**
* Clean refined content by removing conversational text.
*
* @since 0.1.0
* @param string $content Content to clean.
* @return string Cleaned content.
*/
public static function clean_refined_content($content)
{
$conversational_prefixes = [
'Certainly! Here\'s',
'Here\'s',
"The refined content",
"Here is the",
"Below is the",
"Okay, here",
"Sure, here",
];
foreach ($conversational_prefixes as $prefix) {
if (stripos($content, $prefix) === 0) {
$content = substr($content, strlen($prefix));
$content = ltrim($content, ":\n\r ");
}
}
// Remove markdown code blocks if present
$content = preg_replace('/^```(?:text|markdown)?\n*/i', "", $content);
$content = preg_replace('/```*$/i', "", $content);
// Remove common analysis scaffolding
$content = preg_replace("/^\s*Refined version\s*:\s*/im", "", $content);
$content = preg_replace(
'/^\s*Key refinements\s*:\s*$/im',
"",
$content,
);
$content = preg_replace(
'/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im',
"",
$content,
);
// Strip meta markers sections
$meta_markers = [
"Key refinements:",
"Changes made:",
"Explanation:",
"Rationale:",
];
foreach ($meta_markers as $marker) {
$pos = stripos($content, $marker);
if (false !== $pos) {
$content = substr($content, 0, $pos);
}
}
$content = preg_replace("/^\s*content\s*:\s*/im", "", $content);
$content = trim($content);
return $content;
}
/**
* Parse refined payload that may be wrapped in JSON.
*
* @since 0.1.0
* @param string $content Raw model content.
* @return array Parsed payload with content and optional blockType.
*/
public static function parse_refined_payload($content)
{
$payload = [
"content" => $content,
];
if (!is_string($content)) {
return $payload;
}
$trimmed = trim($content);
if ("" === $trimmed) {
return $payload;
}
if ($trimmed[0] !== "{" || substr($trimmed, -1) !== "}") {
return $payload;
}
$decoded = json_decode($trimmed, true);
if (!is_array($decoded)) {
return $payload;
}
if (isset($decoded["content"]) && is_string($decoded["content"])) {
$payload["content"] = $decoded["content"];
}
$block_type = $decoded["blockType"] ?? ($decoded["type"] ?? null);
if (is_string($block_type) && 0 === strpos($block_type, "core/")) {
$payload["blockType"] = $block_type;
}
return $payload;
}
/**
* Detect assistant/meta chatter that should never be inserted as block content.
*
* @since 0.1.0
* @param string $content Refined content candidate.
* @param string $block_type Resolved block type.
* @return bool
*/
public static function is_contaminated_refinement_output(
$content,
$block_type = "core/paragraph",
) {
$text = trim(wp_strip_all_tags((string) $content));
if ("" === $text) {
return true;
}
$meta_patterns = [
"/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i",
"/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i",
'/\b(i have kept the heading|if you\'d like me to refine this)\b/i',
];
foreach ($meta_patterns as $pattern) {
if (preg_match($pattern, $text)) {
return true;
}
}
// Headings should be concise and single-line.
if ("core/heading" === $block_type) {
if (strlen($text) > 180 || substr_count($text, "\n") > 0) {
return true;
}
}
return false;
}
/**
* Build a compact section-scoped context window around a block.
*
* @since 0.1.0
* @param array $all_blocks All serialized editor blocks.
* @param int $block_index Current block index.
* @param int $max_snippets Max context snippets.
* @return string
*/
public static function build_section_context_for_block(
$all_blocks,
$block_index,
$max_snippets = 4,
) {
if (
!is_array($all_blocks) ||
$block_index < 0 ||
!isset($all_blocks[$block_index])
) {
return "";
}
$start = $block_index;
for ($i = $block_index - 1; $i >= 0; $i--) {
$name =
$all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? "");
if ("core/heading" === $name) {
$start = $i;
break;
}
$start = $i;
}
$end = $block_index;
for ($i = $block_index + 1; $i < count($all_blocks); $i++) {
$name =
$all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? "");
if ("core/heading" === $name) {
break;
}
$end = $i;
}
$snippets = [];
for ($i = $start; $i <= $end; $i++) {
$block = $all_blocks[$i];
$name = $block["name"] ?? ($block["blockName"] ?? "");
if (
!in_array(
$name,
[
"core/heading",
"core/paragraph",
"core/list",
"core/quote",
],
true,
)
) {
continue;
}
$attrs = $block["attributes"] ?? ($block["attrs"] ?? []);
$text = trim(
wp_strip_all_tags(
self::extract_block_content_from_attrs($name, $attrs),
),
);
if ("" === $text) {
continue;
}
if (strlen($text) > 180) {
$text = substr($text, 0, 180) . "...";
}
$snippets[] = "- " . $text;
if (count($snippets) >= $max_snippets) {
break;
}
}
return implode("\n", $snippets);
}
/**
* Create block structure for refined content.
*
* @since 0.1.0
* @param string $block_id Block client ID.
* @param string $block_type Block type.
* @param string $content Refined content.
* @return array Block structure.
*/
public static function create_block_structure($block_id, $block_type, $content)
{
if (
preg_match(
'/^!\[(.*?)\]\(([^\s)]+)(?:\s+"[^"]*")?\)\s*$/',
trim($content),
$matches,
)
) {
$alt = trim($matches[1]);
$url = trim($matches[2]);
$escaped_alt = esc_attr($alt);
$escaped_url = esc_url($url);
return [
"blockName" => "core/image",
"attrs" => [
"id" => 0,
"url" => $escaped_url,
"alt" => $alt,
"caption" => "",
"sizeSlug" => "large",
"linkDestination" => "none",
],
"innerHTML" =>
'<figure class="wp-block-image size-large"><img src="' .
$escaped_url .
'" alt="' .
$escaped_alt .
'" /></figure>',
"clientId" => $block_id,
];
}
if ("core/paragraph" === $block_type) {
return [
"blockName" => "core/paragraph",
"attrs" => ["content" => $content],
"innerHTML" => "<p>" . $content . "</p>",
"clientId" => $block_id,
];
} elseif ("core/heading" === $block_type) {
$level = 2;
if (preg_match("/^(#{1,6})\s/", $content)) {
$count = strspn($content, "#");
$level = min($count, 6);
$content = trim(substr($content, $count));
}
$tag = "h" . $level;
return [
"blockName" => "core/heading",
"attrs" => [
"level" => $level,
"content" => $content,
],
"innerHTML" => "<{$tag}>{$content}</{$tag}>",
"clientId" => $block_id,
];
} elseif ("core/list" === $block_type) {
$lines = explode("\n", $content);
$lines = array_filter(array_map("trim", $lines));
$inner_blocks = [];
foreach ($lines as $line) {
$inner_blocks[] = [
"blockName" => "core/list-item",
"attrs" => ["content" => $line],
"innerHTML" => "<li>" . $line . "</li>",
];
}
return [
"blockName" => "core/list",
"attrs" => ["ordered" => false],
"innerBlocks" => $inner_blocks,
"clientId" => $block_id,
];
} elseif ("core/code" === $block_type) {
$language = "text";
$code_content = $content;
if (preg_match("/^```(\w+)?\s*/", $content, $matches)) {
if (!empty($matches[1])) {
$language = $matches[1];
}
$code_content = preg_replace(
"/^```\w*\s*/",
"",
$code_content,
);
$code_content = preg_replace('/```\s*$/', "", $code_content);
$code_content = trim($code_content);
}
$escaped = htmlspecialchars($code_content, ENT_NOQUOTES, "UTF-8");
return [
"blockName" => "core/code",
"attrs" => [
"language" => $language,
"content" => $code_content,
],
"innerHTML" =>
'<pre class="wp-block-code"><code>' .
$escaped .
"</code></pre>",
"clientId" => $block_id,
];
}
// Fallback to paragraph
return [
"blockName" => "core/paragraph",
"attrs" => ["content" => $content],
"innerHTML" => "<p>" . $content . "</p>",
"clientId" => $block_id,
];
}
/**
* Sanitize refinement edit plan with allowlist validation.
*
* @since 0.1.0
* @param array $plan_json Edit plan JSON.
* @param array $allowed_block_ids Allowed block IDs.
* @param array $context_blocks Context blocks for type resolution.
* @return array Sanitized plan.
*/
public static function sanitize_refinement_edit_plan(
$plan_json,
$allowed_block_ids,
$context_blocks,
) {
$allowed_actions = [
"keep",
"replace",
"insert_after",
"insert_before",
"delete",
"change_type",
];
$allowed_lookup = array_fill_keys($allowed_block_ids, true);
$context_by_id = [];
foreach ($context_blocks as $block) {
$context_id = sanitize_text_field($block["clientId"] ?? "");
if ("" === $context_id) {
continue;
}
$context_by_id[$context_id] = sanitize_text_field(
$block["type"] ?? "core/paragraph",
);
}
$raw_actions = $plan_json["actions"] ?? [];
if (!is_array($raw_actions)) {
$raw_actions = [];
}
$sanitized_actions = [];
foreach ($raw_actions as $action) {
if (!is_array($action)) {
continue;
}
$action_name = sanitize_key($action["action"] ?? "");
$block_id = sanitize_text_field($action["blockId"] ?? "");
if (
"" === $action_name ||
!in_array($action_name, $allowed_actions, true)
) {
continue;
}
if ("" === $block_id || !isset($allowed_lookup[$block_id])) {
continue;
}
$clean_action = [
"action" => $action_name,
"blockId" => $block_id,
];
if (
in_array(
$action_name,
["replace", "insert_after", "insert_before", "change_type"],
true,
)
) {
$fallback_type = $context_by_id[$block_id] ?? "core/paragraph";
$clean_action["blockType"] = sanitize_text_field(
$action["blockType"] ?? $fallback_type,
);
$clean_action["content"] = isset($action["content"])
? wp_kses_post((string) $action["content"])
: "";
}
$sanitized_actions[] = $clean_action;
}
return [
"summary" => sanitize_text_field($plan_json["summary"] ?? ""),
"actions" => $sanitized_actions,
];
}
/**
* Parse refined blocks from content split by [Block N] markers.
*
* @since 0.1.0
* @param string $content Raw content.
* @param int $expected_count Expected number of blocks.
* @return array Array of block contents.
*/
public static function parse_refined_blocks($content, $expected_count = 0)
{
$blocks = [];
$parts = preg_split("/\[Block\s*\d+\]/i", $content);
array_shift($parts);
foreach ($parts as $part) {
$block_content = trim($part);
if (!empty($block_content)) {
$blocks[] = $block_content;
}
}
if (empty($blocks) && !empty(trim($content))) {
$blocks[] = trim($content);
}
return $blocks;
}
// =========================================================================
// SEO & Pattern Detection Methods
// =========================================================================
/**
* Scan for AI-ish patterns in content.
*
* @since 0.1.0
* @param string $raw_content Raw content to scan.
* @return array Detection results with count and examples.
*/
public static function scan_ai_ish_patterns($raw_content)
{
$normalized = wp_strip_all_tags((string) $raw_content);
$normalized = preg_replace("/\s+/", " ", $normalized);
$normalized = trim((string) $normalized);
if ("" === $normalized) {
return [
"count" => 0,
"examples" => [],
];
}
$rules = [
[
"id" => "double_colon",
"pattern" => "/[^\s]:\s*:[^\s]/u",
"label" => "double colon punctuation",
],
[
"id" => "ai_phrase_not_only_but",
"pattern" => "/\bbukan sekadar\b|\bnot just\b/i",
"label" => "formulaic contrast phrase",
],
[
"id" => "ai_phrase_in_conclusion",
"pattern" =>
"/\b(pada akhirnya|in conclusion|to summarize)\b/i",
"label" => "template-like conclusion phrase",
],
[
"id" => "meta_instruction_leak",
"pattern" =>
"/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i",
"label" => "instructional/meta leakage",
],
[
"id" => "dash_overuse",
"pattern" => "/\s[—–-]\s/u",
"label" => "dash-heavy sentence style",
],
];
$matches = [];
$total = 0;
foreach ($rules as $rule) {
if (
preg_match_all(
$rule["pattern"],
$normalized,
$found,
PREG_OFFSET_CAPTURE,
)
) {
$total += count($found[0]);
if (count($matches) < 5) {
foreach ($found[0] as $entry) {
if (count($matches) >= 5) {
break;
}
$matched_text = (string) ($entry[0] ?? "");
$offset = (int) ($entry[1] ?? 0);
$context_start = max(0, $offset - 48);
$context = function_exists("mb_substr")
? mb_substr($normalized, $context_start, 120)
: substr($normalized, $context_start, 120);
$matches[] = [
"type" => $rule["label"],
"match" => trim($matched_text),
"context" => trim($context),
];
}
}
}
}
return [
"count" => (int) $total,
"examples" => $matches,
];
}
/**
* Extract unresolved image slots from post content.
*
* @since 0.1.0
* @param string $post_content Post content string.
* @return array Array of unresolved image slot data.
*/
public static function extract_unresolved_image_slots($post_content)
{
$slots = [];
$blocks = parse_blocks((string) $post_content);
$walk = function ($items, $heading_context = "") use (&$walk, &$slots) {
foreach ($items as $block) {
$name = $block["blockName"] ?? "";
$attrs = $block["attrs"] ?? [];
if ("core/heading" === $name) {
$heading = "";
if (!empty($attrs["content"])) {
$heading = trim(
wp_strip_all_tags((string) $attrs["content"]),
);
} elseif (!empty($block["innerHTML"])) {
$heading = trim(
wp_strip_all_tags((string) $block["innerHTML"]),
);
}
if ("" !== $heading) {
$heading_context = $heading;
}
}
if ("core/image" === $name) {
$image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0;
if ($image_id <= 0) {
$slots[] = [
"section_title" => $heading_context,
];
}
}
if (
!empty($block["innerBlocks"]) &&
is_array($block["innerBlocks"])
) {
$walk($block["innerBlocks"], $heading_context);
}
}
};
$walk($blocks, "");
return $slots;
}
// =========================================================================
// Memory & Context Methods
// =========================================================================
/**
* Build a short memory summary from the plan JSON.
*
* @since 0.1.0
* @param array $plan_json Plan data.
* @return string Summary text.
*/
public static function build_memory_summary_from_plan($plan_json)
{
if (empty($plan_json) || !is_array($plan_json)) {
return "";
}
$title = $plan_json["title"] ?? "";
$headings = [];
if (
!empty($plan_json["sections"]) &&
is_array($plan_json["sections"])
) {
foreach ($plan_json["sections"] as $section) {
if (!empty($section["heading"])) {
$headings[] = $section["heading"];
}
}
}
$summary = "";
if ($title) {
$summary .= "Title: {$title}\n";
}
if (!empty($headings)) {
$summary .= "Sections: " . implode(" | ", $headings);
}
return trim($summary);
}
/**
* Build memory context string for prompts.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @return string Context string.
*/
public static function get_post_memory_context($post_id)
{
if ($post_id <= 0) {
return "";
}
$memory = get_post_meta($post_id, "_wpaw_memory", true);
if (empty($memory) || !is_array($memory)) {
return "";
}
$lines = [];
if (!empty($memory["summary"])) {
$lines[] = "Summary: " . $memory["summary"];
}
if (!empty($memory["last_prompt"])) {
$lines[] = "Last prompt: " . $memory["last_prompt"];
}
if (!empty($memory["last_intent"])) {
$lines[] = "Last intent: " . $memory["last_intent"];
}
if (empty($lines)) {
return "";
}
return "\n\n=== POST MEMORY ===\n" .
implode("\n", $lines) .
"\n=== END POST MEMORY ===\n";
}
}