feat: scaffold plugin foundation
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin activation lifecycle hook.
|
||||
*
|
||||
* @package WPContentSync
|
||||
*/
|
||||
|
||||
namespace WPContentSync;
|
||||
|
||||
use WPContentSync\Settings\Settings;
|
||||
use WPContentSync\Settings\SettingsRepository;
|
||||
|
||||
final class Activator {
|
||||
public static function activate(): void {
|
||||
if ( false === get_option( SettingsRepository::OPTION_NAME, false ) ) {
|
||||
update_option( SettingsRepository::OPTION_NAME, Settings::fromArray( array() )->toArray(), false );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin dashboard controller.
|
||||
*
|
||||
* @package WPContentSync
|
||||
*/
|
||||
|
||||
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
|
||||
|
||||
namespace WPContentSync\Admin;
|
||||
|
||||
use WPContentSync\Logging\LoggerInterface;
|
||||
use WPContentSync\Settings\SettingsRepository;
|
||||
|
||||
/**
|
||||
* Registers and renders the WP Content Sync admin page.
|
||||
*/
|
||||
final class AdminPage {
|
||||
/**
|
||||
* Settings storage.
|
||||
*
|
||||
* @var SettingsRepository
|
||||
*/
|
||||
private SettingsRepository $settings_repository;
|
||||
|
||||
/**
|
||||
* Plugin logger.
|
||||
*
|
||||
* @var LoggerInterface
|
||||
*/
|
||||
private LoggerInterface $logger;
|
||||
|
||||
/**
|
||||
* @param SettingsRepository $settings_repository Settings storage.
|
||||
* @param LoggerInterface $logger Plugin logger.
|
||||
*/
|
||||
public function __construct( SettingsRepository $settings_repository, LoggerInterface $logger ) {
|
||||
$this->settings_repository = $settings_repository;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers admin hooks.
|
||||
*/
|
||||
public function register(): void {
|
||||
add_action( 'admin_menu', array( $this, 'registerMenu' ) );
|
||||
add_action( 'admin_init', array( $this, 'registerSettings' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Tools menu page.
|
||||
*/
|
||||
// phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
|
||||
public function registerMenu(): void {
|
||||
add_management_page(
|
||||
__( 'WP Content Sync', 'wp-content-sync' ),
|
||||
__( 'Content Sync', 'wp-content-sync' ),
|
||||
'manage_options',
|
||||
'wp-content-sync',
|
||||
array( $this, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the plugin settings option.
|
||||
*/
|
||||
// phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
|
||||
public function registerSettings(): void {
|
||||
register_setting(
|
||||
'wpcs_settings',
|
||||
SettingsRepository::OPTION_NAME,
|
||||
array(
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => array( $this->settings_repository, 'sanitizeOption' ),
|
||||
'default' => $this->settings_repository->get()->toArray(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the admin dashboard.
|
||||
*/
|
||||
public function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access WP Content Sync.', 'wp-content-sync' ) );
|
||||
}
|
||||
|
||||
$settings = $this->settings_repository->get();
|
||||
$this->logger->debug( 'Admin dashboard viewed.' );
|
||||
|
||||
include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync;
|
||||
|
||||
final class Container {
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $services = array();
|
||||
|
||||
/**
|
||||
* @var array<string, callable(): mixed>
|
||||
*/
|
||||
private array $factories = array();
|
||||
|
||||
/**
|
||||
* @param mixed $service Service instance or value.
|
||||
*/
|
||||
public function set( string $id, $service ): void {
|
||||
$this->services[ $id ] = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(): mixed $factory
|
||||
*/
|
||||
public function factory( string $id, callable $factory ): void {
|
||||
$this->factories[ $id ] = $factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function get( string $id ) {
|
||||
if ( array_key_exists( $id, $this->services ) ) {
|
||||
return $this->services[ $id ];
|
||||
}
|
||||
|
||||
if ( array_key_exists( $id, $this->factories ) ) {
|
||||
$this->services[ $id ] = $this->factories[ $id ]();
|
||||
return $this->services[ $id ];
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException( sprintf( 'Service "%s" is not registered.', $id ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin deactivation lifecycle hook.
|
||||
*
|
||||
* @package WPContentSync
|
||||
*/
|
||||
|
||||
namespace WPContentSync;
|
||||
|
||||
final class Deactivator {
|
||||
public static function deactivate(): void {
|
||||
delete_transient( 'wpcs_active_operation' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Logging;
|
||||
|
||||
interface LoggerInterface {
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function error( string $message, array $context = array() ): void;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function warning( string $message, array $context = array() ): void;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function info( string $message, array $context = array() ): void;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function debug( string $message, array $context = array() ): void;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Logging;
|
||||
|
||||
final class OptionLogger implements LoggerInterface {
|
||||
public const OPTION_NAME = 'wpcs_logs';
|
||||
|
||||
private const SENSITIVE_KEYS = array(
|
||||
'auth',
|
||||
'authorization',
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
);
|
||||
|
||||
private int $max_entries;
|
||||
|
||||
public function __construct( int $max_entries = 200 ) {
|
||||
$this->max_entries = max( 1, $max_entries );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function error( string $message, array $context = array() ): void {
|
||||
$this->log( 'error', $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function warning( string $message, array $context = array() ): void {
|
||||
$this->log( 'warning', $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function info( string $message, array $context = array() ): void {
|
||||
$this->log( 'info', $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
public function debug( string $message, array $context = array() ): void {
|
||||
$this->log( 'debug', $message, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
*/
|
||||
private function log( string $level, string $message, array $context ): void {
|
||||
$entries = get_option( self::OPTION_NAME, array() );
|
||||
$entries = is_array( $entries ) ? $entries : array();
|
||||
|
||||
$entries[] = array(
|
||||
'timestamp' => gmdate( 'c' ),
|
||||
'level' => $level,
|
||||
'message' => sanitize_text_field( $message ),
|
||||
'context' => $this->redactContext( $context ),
|
||||
);
|
||||
|
||||
if ( count( $entries ) > $this->max_entries ) {
|
||||
$entries = array_slice( $entries, -1 * $this->max_entries );
|
||||
}
|
||||
|
||||
update_option( self::OPTION_NAME, $entries, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Log context.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactContext( array $context ): array {
|
||||
$redacted = array();
|
||||
|
||||
foreach ( $context as $key => $value ) {
|
||||
$redacted[ $key ] = $this->isSensitiveKey( (string) $key )
|
||||
? '[redacted]'
|
||||
: $this->redactValue( $value );
|
||||
}
|
||||
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
private function isSensitiveKey( string $key ): bool {
|
||||
$normalized_key = strtolower( $key );
|
||||
|
||||
foreach ( self::SENSITIVE_KEYS as $sensitive_key ) {
|
||||
if ( false !== strpos( $normalized_key, $sensitive_key ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value Context value.
|
||||
* @return mixed
|
||||
*/
|
||||
private function redactValue( $value ) {
|
||||
return is_array( $value ) ? $this->redactContext( $value ) : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin lifecycle coordinator.
|
||||
*
|
||||
* @package WPContentSync
|
||||
*/
|
||||
|
||||
namespace WPContentSync;
|
||||
|
||||
use WPContentSync\Admin\AdminPage;
|
||||
use WPContentSync\Logging\LoggerInterface;
|
||||
use WPContentSync\Logging\OptionLogger;
|
||||
use WPContentSync\Settings\SettingsRepository;
|
||||
|
||||
final class Plugin {
|
||||
private Container $container;
|
||||
|
||||
private function __construct( Container $container ) {
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
public static function create(): self {
|
||||
$container = new Container();
|
||||
|
||||
$container->factory(
|
||||
SettingsRepository::class,
|
||||
static function (): SettingsRepository {
|
||||
return new SettingsRepository();
|
||||
}
|
||||
);
|
||||
|
||||
$container->factory(
|
||||
LoggerInterface::class,
|
||||
static function (): LoggerInterface {
|
||||
return new OptionLogger();
|
||||
}
|
||||
);
|
||||
|
||||
$container->factory(
|
||||
AdminPage::class,
|
||||
static function () use ( $container ): AdminPage {
|
||||
return new AdminPage(
|
||||
$container->get( SettingsRepository::class ),
|
||||
$container->get( LoggerInterface::class )
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return new self( $container );
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
/** @var AdminPage $admin_page */
|
||||
$admin_page = $this->container->get( AdminPage::class );
|
||||
|
||||
$admin_page->register();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Settings;
|
||||
|
||||
final class Settings {
|
||||
private const LOGGING_LEVELS = array( 'error', 'warning', 'info', 'debug' );
|
||||
private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );
|
||||
|
||||
/**
|
||||
* @var array<int, array{name: string, source_url: string, destination_url: string}>
|
||||
*/
|
||||
private array $sync_pairs;
|
||||
|
||||
private string $logging_level;
|
||||
private bool $automatic_url_replacement;
|
||||
private string $conflict_strategy;
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, source_url: string, destination_url: string}> $sync_pairs Sync pairs.
|
||||
*/
|
||||
private function __construct(
|
||||
array $sync_pairs,
|
||||
string $logging_level,
|
||||
bool $automatic_url_replacement,
|
||||
string $conflict_strategy
|
||||
) {
|
||||
$this->sync_pairs = $sync_pairs;
|
||||
$this->logging_level = $logging_level;
|
||||
$this->automatic_url_replacement = $automatic_url_replacement;
|
||||
$this->conflict_strategy = $conflict_strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Raw settings data.
|
||||
*/
|
||||
public static function fromArray( array $data ): self {
|
||||
$logging_level = self::sanitizeChoice(
|
||||
$data['logging_level'] ?? 'warning',
|
||||
self::LOGGING_LEVELS,
|
||||
'warning'
|
||||
);
|
||||
|
||||
$conflict_strategy = self::sanitizeChoice(
|
||||
$data['conflict_strategy'] ?? 'last_write_wins',
|
||||
self::CONFLICT_STRATEGIES,
|
||||
'last_write_wins'
|
||||
);
|
||||
|
||||
$automatic_url_replacement = array_key_exists( 'automatic_url_replacement', $data )
|
||||
? self::sanitizeBoolean( $data['automatic_url_replacement'] )
|
||||
: true;
|
||||
|
||||
return new self(
|
||||
self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ),
|
||||
$logging_level,
|
||||
$automatic_url_replacement,
|
||||
$conflict_strategy
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, source_url: string, destination_url: string}>
|
||||
*/
|
||||
public function syncPairs(): array {
|
||||
return $this->sync_pairs;
|
||||
}
|
||||
|
||||
public function loggingLevel(): string {
|
||||
return $this->logging_level;
|
||||
}
|
||||
|
||||
public function automaticUrlReplacementEnabled(): bool {
|
||||
return $this->automatic_url_replacement;
|
||||
}
|
||||
|
||||
public function conflictStrategy(): string {
|
||||
return $this->conflict_strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return array(
|
||||
'sync_pairs' => $this->sync_pairs,
|
||||
'logging_level' => $this->logging_level,
|
||||
'automatic_url_replacement' => $this->automatic_url_replacement,
|
||||
'conflict_strategy' => $this->conflict_strategy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value Value to sanitize.
|
||||
* @param array<int, string> $allowed Allowed values.
|
||||
*/
|
||||
private static function sanitizeChoice( $value, array $allowed, string $fallback ): string {
|
||||
$sanitized = sanitize_text_field( (string) $value );
|
||||
|
||||
return in_array( $sanitized, $allowed, true ) ? $sanitized : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value Value to normalize.
|
||||
*/
|
||||
private static function sanitizeBoolean( $value ): bool {
|
||||
if ( is_bool( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$normalized = strtolower( sanitize_text_field( (string) $value ) );
|
||||
|
||||
return in_array( $normalized, array( '1', 'true', 'yes', 'on' ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $pairs Raw sync pairs.
|
||||
* @return array<int, array{name: string, source_url: string, destination_url: string}>
|
||||
*/
|
||||
private static function sanitizeSyncPairs( $pairs ): array {
|
||||
if ( ! is_array( $pairs ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$sanitized = array();
|
||||
|
||||
foreach ( $pairs as $pair ) {
|
||||
if ( ! is_array( $pair ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = sanitize_text_field( (string) ( $pair['name'] ?? '' ) );
|
||||
$source_url = esc_url_raw( (string) ( $pair['source_url'] ?? '' ) );
|
||||
$destination_url = esc_url_raw( (string) ( $pair['destination_url'] ?? '' ) );
|
||||
|
||||
if ( '' === $name || '' === $source_url || '' === $destination_url ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = array(
|
||||
'name' => $name,
|
||||
'source_url' => $source_url,
|
||||
'destination_url' => $destination_url,
|
||||
);
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Settings;
|
||||
|
||||
final class SettingsRepository {
|
||||
public const OPTION_NAME = 'wpcs_settings';
|
||||
|
||||
public function get(): Settings {
|
||||
$value = get_option( self::OPTION_NAME, array() );
|
||||
|
||||
return Settings::fromArray( is_array( $value ) ? $value : array() );
|
||||
}
|
||||
|
||||
public function save( Settings $settings ): void {
|
||||
update_option( self::OPTION_NAME, $settings->toArray(), false );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value Value to sanitize.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitizeOption( $value ): array {
|
||||
return Settings::fromArray( is_array( $value ) ? $value : array() )->toArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user