mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-12 02:03:55 +00:00
Sweeps shipped copy clean of em dashes: readme.txt, README.md, CHANGELOG.md, the bundled sample prompts, and user-facing PHP strings (tool descriptions, admin labels). PHP was swept on non-comment lines only, so developer docblocks and inline comments are untouched. Internal dev docs (CLAUDE.md, docs/) were deliberately left alone. Replacements are contextual: colons for label/definition patterns, commas for asides, en dashes for numeric ranges. The .mcpb bundle display_name now reads "EMCP Tools - <host>" with a plain hyphen; its test is updated to match. Fixing CHANGELOG.md also clears the em dashes from the website's generated changelog on the next site build, since that file is its source. Verified: php -l clean on every touched file, pro suite 1559 pass, public suite 102 pass. Bumps the pro submodule for the Pro-side sweep.
367 lines
9.6 KiB
PHP
367 lines
9.6 KiB
PHP
<?php
|
|
/**
|
|
* Unified change ledger + rollback dispatcher (AI-safe transactions).
|
|
*
|
|
* A single recorder every write site calls, storing lightweight rollback-capable
|
|
* entries in one capped option, plus a rollback dispatcher that undoes an entry
|
|
* via the right mechanism (re-save prior Elementor data, restore/delete a file
|
|
* backup, or inverse a $wpdb write from a before-image).
|
|
*
|
|
* @package EMCP_Tools
|
|
* @since 3.3.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* The change ledger.
|
|
*
|
|
* @since 3.3.0
|
|
*/
|
|
class EMCP_Tools_Change_Log {
|
|
|
|
const OPTION = 'emcp_tools_changelog';
|
|
const MAX_COUNT = 200;
|
|
const MAX_BYTES = 1048576; // ~1 MB.
|
|
|
|
/**
|
|
* When true, record() is a no-op. Set during rollback so the rollback's own
|
|
* write (e.g. re-saving Elementor data) does not create a spurious entry.
|
|
*
|
|
* @var bool
|
|
*/
|
|
public static $suppress = false;
|
|
|
|
/**
|
|
* Append an entry. Returns its id, or '' when suppressed.
|
|
*
|
|
* @param array $entry { domain, action, target?, summary?, rollback? }.
|
|
* @return string
|
|
*/
|
|
public static function record( array $entry ): string {
|
|
if ( self::$suppress ) {
|
|
return '';
|
|
}
|
|
$id = self::uid();
|
|
$entry = array_merge(
|
|
array(
|
|
'target' => '',
|
|
'summary' => '',
|
|
'rollback' => null,
|
|
),
|
|
$entry,
|
|
array(
|
|
'id' => $id,
|
|
'ts' => time(),
|
|
'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0,
|
|
'user_login' => self::current_login(),
|
|
'rolled_back' => false,
|
|
)
|
|
);
|
|
$log = self::all();
|
|
$log[] = $entry;
|
|
update_option( self::OPTION, self::cap( $log ), false );
|
|
return $id;
|
|
}
|
|
|
|
/**
|
|
* All entries, oldest-first.
|
|
*
|
|
* @return array[]
|
|
*/
|
|
public static function all(): array {
|
|
$log = get_option( self::OPTION, array() );
|
|
return is_array( $log ) ? array_values( $log ) : array();
|
|
}
|
|
|
|
/**
|
|
* Fetch an entry by id.
|
|
*
|
|
* @param string $id Entry id.
|
|
* @return array|null
|
|
*/
|
|
public static function get( string $id ): ?array {
|
|
foreach ( self::all() as $e ) {
|
|
if ( isset( $e['id'] ) && $e['id'] === $id ) {
|
|
return $e;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Flag an entry as rolled back.
|
|
*
|
|
* @param string $id Entry id.
|
|
*/
|
|
public static function mark_rolled_back( string $id ): void {
|
|
$log = self::all();
|
|
foreach ( $log as &$e ) {
|
|
if ( isset( $e['id'] ) && $e['id'] === $id ) {
|
|
$e['rolled_back'] = true;
|
|
}
|
|
}
|
|
unset( $e );
|
|
update_option( self::OPTION, $log, false );
|
|
}
|
|
|
|
/**
|
|
* Delete a single entry from the ledger.
|
|
*
|
|
* Note: an entry carries its own before-image, so deleting a reversible
|
|
* entry permanently forfeits the ability to roll it back. The admin UI
|
|
* warns about this before calling.
|
|
*
|
|
* @since 3.4.2
|
|
* @param string $id Entry id.
|
|
* @return bool True when an entry was removed.
|
|
*/
|
|
public static function delete( string $id ): bool {
|
|
if ( '' === $id ) {
|
|
return false;
|
|
}
|
|
$log = self::all();
|
|
$kept = array();
|
|
$found = false;
|
|
foreach ( $log as $e ) {
|
|
if ( ! $found && isset( $e['id'] ) && $e['id'] === $id ) {
|
|
$found = true;
|
|
continue;
|
|
}
|
|
$kept[] = $e;
|
|
}
|
|
if ( ! $found ) {
|
|
return false;
|
|
}
|
|
update_option( self::OPTION, array_values( $kept ), false );
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Wipe the whole ledger.
|
|
*
|
|
* @since 3.4.2
|
|
* @return int Number of entries removed.
|
|
*/
|
|
public static function clear(): int {
|
|
$count = count( self::all() );
|
|
update_option( self::OPTION, array(), false );
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Enforce count + size caps by dropping the oldest entries.
|
|
*
|
|
* @param array $log Entries.
|
|
* @return array
|
|
*/
|
|
private static function cap( array $log ): array {
|
|
if ( count( $log ) > self::MAX_COUNT ) {
|
|
$log = array_slice( $log, -self::MAX_COUNT );
|
|
}
|
|
while ( count( $log ) > 1 && strlen( (string) wp_json_encode( $log ) ) > self::MAX_BYTES ) {
|
|
array_shift( $log );
|
|
}
|
|
return array_values( $log );
|
|
}
|
|
|
|
/**
|
|
* Undo an entry by id, dispatching on its rollback type. Marks the entry
|
|
* rolled_back and records a compensating entry.
|
|
*
|
|
* @param string $id Entry id.
|
|
* @return array|WP_Error
|
|
*/
|
|
public static function rollback( string $id ) {
|
|
$entry = self::get( $id );
|
|
if ( null === $entry ) {
|
|
return new WP_Error( 'not_found', __( 'Change not found.', 'emcp-tools' ) );
|
|
}
|
|
if ( ! empty( $entry['rolled_back'] ) ) {
|
|
return new WP_Error( 'already_rolled_back', __( 'This change has already been rolled back.', 'emcp-tools' ) );
|
|
}
|
|
$rb = ( isset( $entry['rollback'] ) && is_array( $entry['rollback'] ) ) ? $entry['rollback'] : null;
|
|
if ( null === $rb ) {
|
|
return new WP_Error( 'not_reversible', __( 'This change is not reversible.', 'emcp-tools' ) );
|
|
}
|
|
|
|
self::$suppress = true;
|
|
try {
|
|
$result = self::apply_rollback( $rb );
|
|
} catch ( \Throwable $e ) {
|
|
$result = new WP_Error( 'rollback_failed', $e->getMessage() );
|
|
} finally {
|
|
self::$suppress = false;
|
|
}
|
|
if ( is_wp_error( $result ) ) {
|
|
return $result;
|
|
}
|
|
|
|
self::mark_rolled_back( $id );
|
|
$comp = self::record( array(
|
|
'domain' => $entry['domain'] ?? '',
|
|
'action' => 'rollback',
|
|
'target' => $entry['target'] ?? '',
|
|
'summary' => 'Rolled back: ' . ( $entry['summary'] ?? $id ),
|
|
'rollback' => null,
|
|
) );
|
|
return array(
|
|
'rolled_back' => $id,
|
|
'compensating' => $comp,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dispatch a rollback by type.
|
|
*
|
|
* @param array $rb Rollback ref.
|
|
* @return true|WP_Error
|
|
*/
|
|
private static function apply_rollback( array $rb ) {
|
|
switch ( $rb['type'] ?? '' ) {
|
|
case 'elementor-data':
|
|
return self::rollback_elementor( $rb );
|
|
case 'file-backup':
|
|
return self::rollback_file_restore( $rb );
|
|
case 'file-create':
|
|
return self::rollback_file_delete( $rb );
|
|
case 'db-before-image':
|
|
return self::rollback_db( $rb );
|
|
default:
|
|
return new WP_Error( 'unknown_rollback', __( 'Unknown rollback type.', 'emcp-tools' ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore a page's prior Elementor data.
|
|
*
|
|
* @param array $rb Rollback ref.
|
|
* @return true|WP_Error
|
|
*/
|
|
private static function rollback_elementor( array $rb ) {
|
|
$post_id = (int) ( $rb['post_id'] ?? 0 );
|
|
$before = ( isset( $rb['before'] ) && is_array( $rb['before'] ) ) ? $rb['before'] : array();
|
|
if ( $post_id <= 0 || ! class_exists( 'EMCP_Tools_Data' ) ) {
|
|
return new WP_Error( 'rollback_failed', __( 'Cannot restore this page.', 'emcp-tools' ) );
|
|
}
|
|
$data = new EMCP_Tools_Data();
|
|
$res = $data->save_page_data( $post_id, $before );
|
|
return is_wp_error( $res ) ? $res : true;
|
|
}
|
|
|
|
/**
|
|
* Restore a file from its backup (ABSPATH-confined).
|
|
*
|
|
* @param array $rb Rollback ref.
|
|
* @return true|WP_Error
|
|
*/
|
|
private static function rollback_file_restore( array $rb ) {
|
|
$target = (string) ( $rb['target_path'] ?? '' );
|
|
$backup = (string) ( $rb['backup_path'] ?? '' );
|
|
if ( '' === $target || '' === $backup || ! is_file( $backup ) ) {
|
|
return new WP_Error( 'rollback_failed', __( 'Backup is unavailable.', 'emcp-tools' ) );
|
|
}
|
|
$safe = self::guard_target( $target );
|
|
if ( is_wp_error( $safe ) ) {
|
|
return $safe;
|
|
}
|
|
return copy( $backup, $safe ) ? true : new WP_Error( 'rollback_failed', __( 'Could not restore the file.', 'emcp-tools' ) );
|
|
}
|
|
|
|
/**
|
|
* Delete a file that a recorded write created (ABSPATH-confined).
|
|
*
|
|
* @param array $rb Rollback ref.
|
|
* @return true|WP_Error
|
|
*/
|
|
private static function rollback_file_delete( array $rb ) {
|
|
$target = (string) ( $rb['target_path'] ?? '' );
|
|
if ( '' === $target || ! is_file( $target ) ) {
|
|
return true; // Already gone, nothing to undo.
|
|
}
|
|
$safe = self::guard_target( $target );
|
|
if ( is_wp_error( $safe ) ) {
|
|
return $safe;
|
|
}
|
|
return @unlink( $safe ) ? true : new WP_Error( 'rollback_failed', __( 'Could not delete the created file.', 'emcp-tools' ) );
|
|
}
|
|
|
|
/**
|
|
* Inverse a database write from its before-image.
|
|
*
|
|
* @param array $rb Rollback ref.
|
|
* @return true|WP_Error
|
|
*/
|
|
private static function rollback_db( array $rb ) {
|
|
global $wpdb;
|
|
$table = (string) ( $rb['table'] ?? '' );
|
|
$op = (string) ( $rb['op'] ?? '' );
|
|
if ( '' === $table ) {
|
|
return new WP_Error( 'rollback_failed', __( 'Missing table.', 'emcp-tools' ) );
|
|
}
|
|
$keys = (array) ( $rb['key_cols'] ?? array() );
|
|
switch ( $op ) {
|
|
case 'update':
|
|
foreach ( (array) ( $rb['before_rows'] ?? array() ) as $row ) {
|
|
$where = array();
|
|
foreach ( $keys as $c ) {
|
|
if ( is_array( $row ) && array_key_exists( $c, $row ) ) {
|
|
$where[ $c ] = $row[ $c ];
|
|
}
|
|
}
|
|
$wpdb->update( $table, $row, $where );
|
|
}
|
|
return true;
|
|
case 'delete':
|
|
foreach ( (array) ( $rb['before_rows'] ?? array() ) as $row ) {
|
|
$wpdb->insert( $table, $row );
|
|
}
|
|
return true;
|
|
case 'insert':
|
|
$wpdb->delete( $table, (array) ( $rb['inserted_key'] ?? array() ) );
|
|
return true;
|
|
default:
|
|
return new WP_Error( 'rollback_failed', __( 'Unknown DB operation.', 'emcp-tools' ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confine a filesystem target to ABSPATH via the shared guard.
|
|
*
|
|
* @param string $target Absolute path.
|
|
* @return string|WP_Error Canonical path or error.
|
|
*/
|
|
private static function guard_target( string $target ) {
|
|
if ( class_exists( 'EMCP_Tools_Filesystem_Guard' ) ) {
|
|
return EMCP_Tools_Filesystem_Guard::resolve_path( $target );
|
|
}
|
|
return $target;
|
|
}
|
|
|
|
/**
|
|
* A short unique id.
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function uid(): string {
|
|
return substr( md5( uniqid( '', true ) ), 0, 12 );
|
|
}
|
|
|
|
/**
|
|
* Current user login (best-effort).
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function current_login(): string {
|
|
if ( function_exists( 'wp_get_current_user' ) ) {
|
|
$u = wp_get_current_user();
|
|
if ( $u && isset( $u->user_login ) ) {
|
|
return (string) $u->user_login;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
}
|