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.
1404 lines
42 KiB
PHP
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";
|
|
}
|
|
}
|