Files
wp-agentic-writer/includes/class-conversation-manager.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

752 lines
19 KiB
PHP

<?php
/**
* Conversation Manager
*
* Handles session-based chat history with MySQL table storage.
* Supports both post-linked and standalone sessions.
*
* @package WP_Agentic_Writer
*/
if (!defined("ABSPATH")) {
exit();
}
class WP_Agentic_Writer_Conversation_Manager
{
/**
* Singleton instance
*
* @var WP_Agentic_Writer_Conversation_Manager
*/
private static $instance = null;
/**
* Database table name
*
* @var string
*/
private $table_name;
/**
* Current session ID
*
* @var string
*/
private $current_session_id = null;
/**
* Get singleton instance
*
* @return WP_Agentic_Writer_Conversation_Manager
*/
public static function get_instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct()
{
global $wpdb;
$this->table_name = $wpdb->prefix . "wpaw_conversations";
}
/**
* Generate a unique session ID
*
* @return string
*/
public function generate_session_id()
{
if (function_exists("wp_generate_uuid4")) {
return str_replace("-", "", wp_generate_uuid4());
}
// Fallback for older WP versions (UUID v4 format, without hyphens).
return str_replace("-", "", sprintf(
"%04x%04x-%04x-%04x-%04x-%04x%04x%04x",
wp_rand(0, 0xffff),
wp_rand(0, 0xffff),
wp_rand(0, 0xffff),
wp_rand(0, 0x0fff) | 0x4000,
wp_rand(0, 0x3fff) | 0x8000,
wp_rand(0, 0xffff),
wp_rand(0, 0xffff),
wp_rand(0, 0xffff),
));
}
/**
* Create a new conversation session
*
* @param array $data Session data.
* @return string|WP_Error Session ID or error.
*/
public function create_session($data = [])
{
global $wpdb;
$session_id = $this->generate_session_id();
$user_id = get_current_user_id();
$result = $wpdb->insert(
$this->table_name,
[
"session_id" => $session_id,
"user_id" => $user_id,
"post_id" => isset($data["post_id"])
? (int) $data["post_id"]
: 0,
"title" => isset($data["title"])
? sanitize_text_field($data["title"])
: "",
"focus_keyword" => isset($data["focus_keyword"])
? sanitize_text_field($data["focus_keyword"])
: "",
"messages" => isset($data["messages"])
? json_encode($data["messages"])
: "[]",
"context" => isset($data["context"])
? json_encode($data["context"])
: "{}",
"status" => "active",
],
["%s", "%d", "%d", "%s", "%s", "%s", "%s", "%s"],
);
if (false === $result) {
return new WP_Error(
"db_error",
__("Failed to create session.", "wp-agentic-writer"),
["status" => 500],
);
}
$this->current_session_id = $session_id;
return $session_id;
}
/**
* Check if current user can access a session
*
* @param string $session_id Session ID.
* @return bool True if user can access.
*/
public function current_user_can_access($session_id)
{
$session = $this->get_session($session_id);
if (!$session) {
return false;
}
$current_user_id = get_current_user_id();
// User owns this session
if ((int) $session["user_id"] === $current_user_id) {
return true;
}
// For post-linked sessions, check if user can edit the post
if (!empty($session["post_id"])) {
$post_id = (int) $session["post_id"];
if ($post_id > 0 && current_user_can("edit_post", $post_id)) {
return true;
}
}
return false;
}
/**
* Get a session by session ID (with authorization check)
*
* @param string $session_id Session ID.
* @return array|null Session data or null if not found/not authorized.
*/
public function get_session($session_id)
{
global $wpdb;
$session = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table_name} WHERE session_id = %s",
$session_id,
),
ARRAY_A,
);
if (!$session) {
return null;
}
// Decode JSON fields
$session["messages"] = json_decode($session["messages"], true) ?: [];
$session["context"] = json_decode($session["context"], true) ?: [];
return $session;
}
/**
* Get a session by session ID (public - for internal use only)
* Use this only when authorization is handled separately
*
* @param string $session_id Session ID.
* @return array|null Session data or null if not found.
*/
public function get_session_unchecked($session_id)
{
global $wpdb;
$session = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table_name} WHERE session_id = %s",
$session_id,
),
ARRAY_A,
);
if (!$session) {
return null;
}
// Decode JSON fields
$session["messages"] = json_decode($session["messages"], true) ?: [];
$session["context"] = json_decode($session["context"], true) ?: [];
return $session;
}
/**
* Get session by post ID
*
* @param int $post_id Post ID.
* @return array|null Session data or null.
*/
public function get_session_by_post_id($post_id)
{
global $wpdb;
$session = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table_name} WHERE post_id = %d AND status != 'archived' ORDER BY updated_at DESC LIMIT 1",
$post_id,
),
ARRAY_A,
);
if (!$session) {
return null;
}
$session["messages"] = json_decode($session["messages"], true) ?: [];
$session["context"] = json_decode($session["context"], true) ?: [];
return $session;
}
/**
* Get all active sessions for current user
*
* @param string $status Status filter (active, completed, archived).
* @param int $limit Number of results.
* @return array Sessions list.
*/
public function get_user_sessions($status = "active", $limit = 20)
{
global $wpdb;
$user_id = get_current_user_id();
$posts_table = $wpdb->posts;
$sessions = $wpdb->get_results(
$wpdb->prepare(
"SELECT c.id, c.session_id, c.post_id, c.title, c.focus_keyword, c.status, c.created_at, c.updated_at,
JSON_LENGTH(c.messages) as message_count,
COALESCE(p.post_status, '') as post_status
FROM {$this->table_name} c
LEFT JOIN {$posts_table} p ON p.ID = c.post_id
WHERE c.user_id = %d AND c.status = %s
ORDER BY updated_at DESC
LIMIT %d",
$user_id,
$status,
$limit,
),
ARRAY_A,
);
return $sessions ?: [];
}
/**
* Get all uncompleted sessions (post_id = 0) for current user
*
* @param int $limit Number of results.
* @return array Sessions list.
*/
public function get_uncompleted_sessions($limit = 20)
{
global $wpdb;
$user_id = get_current_user_id();
$sessions = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, session_id, post_id, title, focus_keyword, status, created_at, updated_at,
JSON_LENGTH(messages) as message_count
FROM {$this->table_name}
WHERE user_id = %d AND post_id = 0 AND status = 'active'
ORDER BY updated_at DESC
LIMIT %d",
$user_id,
$limit,
),
ARRAY_A,
);
return $sessions ?: [];
}
/**
* Update session messages
*
* @param string $session_id Session ID.
* @param array $messages Messages array.
* @return bool True on success.
*/
public function update_messages($session_id, $messages)
{
global $wpdb;
$result = $wpdb->update(
$this->table_name,
[
"messages" => json_encode($messages),
"updated_at" => current_time("mysql"),
],
["session_id" => $session_id],
["%s", "%s"],
["%s"],
);
return false !== $result;
}
/**
* Update session context
*
* @param string $session_id Session ID.
* @param array $context Context data.
* @return bool True on success.
*/
public function update_context($session_id, $context)
{
global $wpdb;
$result = $wpdb->update(
$this->table_name,
[
"context" => json_encode($context),
"updated_at" => current_time("mysql"),
],
["session_id" => $session_id],
["%s", "%s"],
["%s"],
);
return false !== $result;
}
/**
* Link session to a post
*
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @return bool True on success.
*/
public function link_to_post($session_id, $post_id)
{
global $wpdb;
$result = $wpdb->update(
$this->table_name,
[
"post_id" => (int) $post_id,
"updated_at" => current_time("mysql"),
],
["session_id" => $session_id],
["%d", "%s"],
["%s"],
);
return false !== $result;
}
/**
* Update session title
*
* @param string $session_id Session ID.
* @param string $title New title.
* @return bool True on success.
*/
public function update_title($session_id, $title)
{
global $wpdb;
$result = $wpdb->update(
$this->table_name,
[
"title" => sanitize_text_field($title),
"updated_at" => current_time("mysql"),
],
["session_id" => $session_id],
["%s", "%s"],
["%s"],
);
return false !== $result;
}
/**
* Update focus keyword
*
* @param string $session_id Session ID.
* @param string $focus_keyword Focus keyword.
* @return bool True on success.
*/
public function update_focus_keyword($session_id, $focus_keyword)
{
global $wpdb;
$result = $wpdb->update(
$this->table_name,
[
"focus_keyword" => sanitize_text_field($focus_keyword),
"updated_at" => current_time("mysql"),
],
["session_id" => $session_id],
["%s", "%s"],
["%s"],
);
return false !== $result;
}
/**
* Mark session as completed
*
* @param string $session_id Session ID.
* @return bool True on success.
*/
public function mark_completed($session_id)
{
global $wpdb;
$result = $wpdb->update(
$this->table_name,
[
"status" => "completed",
"updated_at" => current_time("mysql"),
],
["session_id" => $session_id],
["%s", "%s"],
["%s"],
);
return false !== $result;
}
/**
* Delete a session
*
* @param string $session_id Session ID.
* @return bool True on success.
*/
public function delete_session($session_id)
{
global $wpdb;
$result = $wpdb->delete(
$this->table_name,
["session_id" => $session_id],
["%s"],
);
return false !== $result;
}
/**
* Get or create session for post
*
* @param int $post_id Post ID (can be 0 for new posts).
* @return array Session data with session_id.
*/
public function get_or_create_session_for_post($post_id = 0)
{
// Try to find existing session for this post
if ($post_id > 0) {
$session = $this->get_session_by_post_id($post_id);
if ($session) {
return $session;
}
}
// Create new session
$session_id = $this->create_session(["post_id" => $post_id]);
if (is_wp_error($session_id)) {
return null;
}
return $this->get_session($session_id);
}
/**
* Set current session ID
*
* @param string $session_id Session ID.
*/
public function set_current_session($session_id)
{
$this->current_session_id = $session_id;
}
/**
* Get current session ID
*
* @return string|null
*/
public function get_current_session_id()
{
return $this->current_session_id;
}
/**
* Get current session data
*
* @return array|null
*/
public function get_current_session()
{
if (!$this->current_session_id) {
return null;
}
return $this->get_session($this->current_session_id);
}
/**
* Check if current session has post ID
*
* @return bool
*/
public function current_session_has_post()
{
$session = $this->get_current_session();
return $session && $session["post_id"] > 0;
}
/**
* Check if editor has content (for auto-save decision)
*
* @param int $post_id Post ID.
* @return bool True if post has content blocks.
*/
public function post_has_content($post_id)
{
if ($post_id <= 0) {
return false;
}
$post = get_post($post_id);
if (!$post) {
return false;
}
// Check if post has any blocks or content
$blocks = parse_blocks($post->post_content);
return !empty($blocks);
}
/**
* Get all sessions for a specific post.
*
* @param int $post_id Post ID.
* @return array Sessions array.
*/
public function get_sessions_for_post($post_id)
{
global $wpdb;
if ($post_id <= 0) {
return [];
}
$sessions = $wpdb->get_results(
$wpdb->prepare(
"SELECT *, JSON_LENGTH(messages) as message_count FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC",
$post_id,
),
ARRAY_A,
);
// Decode JSON fields for each session
foreach ($sessions as &$session) {
$session["messages"] =
json_decode($session["messages"], true) ?: [];
$session["context"] = json_decode($session["context"], true) ?: [];
}
return $sessions ?: [];
}
/**
* Acquire or refresh a session edit lock.
*
* Lock format: "timestamp:user_id:tab_id"
* A lock is stale if its timestamp is older than $window seconds.
*
* @param string $session_id Session ID.
* @param string $tab_id Unique browser tab identifier.
* @param int $window Lock expiry window in seconds (default 60).
* @return array { locked: bool, holder?: array }
*/
public function acquire_lock($session_id, $tab_id, $window = 60)
{
global $wpdb;
$user_id = get_current_user_id();
$now = time();
// Read current lock
$current_lock = $wpdb->get_var(
$wpdb->prepare(
"SELECT edit_lock FROM {$this->table_name} WHERE session_id = %s",
$session_id,
),
);
if ($current_lock) {
$parts = explode(":", $current_lock);
$lock_time = (int) ($parts[0] ?? 0);
$lock_user = (int) ($parts[1] ?? 0);
$lock_tab = $parts[2] ?? "";
// Lock is still fresh AND held by a different tab
if ($now - $lock_time < $window && $lock_tab !== $tab_id) {
return [
"locked" => false,
"holder" => [
"user_id" => $lock_user,
"tab_id" => $lock_tab,
"since" => $lock_time,
],
];
}
}
// Acquire / refresh the lock
$lock_value = "{$now}:{$user_id}:{$tab_id}";
$wpdb->update(
$this->table_name,
["edit_lock" => $lock_value],
["session_id" => $session_id],
["%s"],
["%s"],
);
return ["locked" => true];
}
/**
* Release a session edit lock (only if held by this tab).
*
* @param string $session_id Session ID.
* @param string $tab_id Browser tab identifier.
* @return bool True if released.
*/
public function release_lock($session_id, $tab_id)
{
global $wpdb;
$current_lock = $wpdb->get_var(
$wpdb->prepare(
"SELECT edit_lock FROM {$this->table_name} WHERE session_id = %s",
$session_id,
),
);
if (!$current_lock) {
return true;
}
$parts = explode(":", $current_lock);
$lock_tab = $parts[2] ?? "";
if ($lock_tab !== $tab_id) {
return false;
}
$wpdb->update(
$this->table_name,
["edit_lock" => null],
["session_id" => $session_id],
["%s"],
["%s"],
);
return true;
}
/**
* Check the lock status of a session without modifying it.
*
* @param string $session_id Session ID.
* @param string $tab_id Current tab identifier.
* @param int $window Lock expiry window in seconds.
* @return array { is_locked: bool, is_mine: bool, holder?: array }
*/
public function check_lock($session_id, $tab_id = "", $window = 60)
{
global $wpdb;
$now = time();
$current_lock = $wpdb->get_var(
$wpdb->prepare(
"SELECT edit_lock FROM {$this->table_name} WHERE session_id = %s",
$session_id,
),
);
if (!$current_lock) {
return ["is_locked" => false, "is_mine" => false];
}
$parts = explode(":", $current_lock);
$lock_time = (int) ($parts[0] ?? 0);
$lock_user = (int) ($parts[1] ?? 0);
$lock_tab = $parts[2] ?? "";
// Expired
if ($now - $lock_time >= $window) {
return ["is_locked" => false, "is_mine" => false];
}
$is_mine = $lock_tab === $tab_id;
return [
"is_locked" => true,
"is_mine" => $is_mine,
"holder" => [
"user_id" => $lock_user,
"tab_id" => $lock_tab,
"since" => $lock_time,
],
];
}
}