feat: scaffold plugin foundation

This commit is contained in:
Keith Solomon
2026-04-26 12:44:16 -05:00
commit 557657344d
24 changed files with 5238 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.phpunit.result.cache
/.vscode/
+40
View File
@@ -0,0 +1,40 @@
{
"name": "ksolo/wp-content-sync",
"description": "Bidirectional WordPress content synchronization plugin.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.4"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"php-stubs/wordpress-stubs": "^6.8",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.9",
"wp-coding-standards/wpcs": "^3.0"
},
"autoload": {
"psr-4": {
"WPContentSync\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"WPContentSync\\Tests\\": "tests/"
}
},
"scripts": {
"validate": "composer validate --strict",
"lint": "phpcs",
"lint:fix": "phpcbf",
"stan": "phpstan analyse",
"test": "phpunit"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"sort-packages": true
}
}
Generated
+2339
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
# WordPress Content Sync Implementation Roadmap
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement the linked plans task-by-task.
**Source Spec:** `docs/superpowers/specs/2026-04-25-wordpress-content-sync-design.md`
**Purpose:** Break the approved product design into independently shippable implementation plans.
## Delivery Strategy
Build the plugin in small vertical slices. Each slice must leave the repository in a working, testable state and must preserve the project agreements: PHPCS using `phpcs.xml`, PHPStan level 6 or higher, PHPUnit, secure nonce checks, sanitized input, escaped output, and no direct reliance on git or WP-CLI at runtime.
## Phase 1: Plugin Foundation
**Plan:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-foundation.md`
Creates the WordPress plugin skeleton, Composer/dev tooling, bootstrap loader, activation/deactivation hooks, service container, admin shell, settings persistence, logging, and initial PHPUnit coverage.
**Exit Criteria:**
- WordPress can load the plugin without fatal errors.
- Composer scripts exist for `lint`, `stan`, `test`, and `validate`.
- Admin menu renders a dashboard shell for users with `manage_options`.
- Plugin settings can be registered and read through a typed settings object.
- Unit tests pass for settings defaults, service registration, and logger behavior.
## Phase 2: URL Transformation
**Plan to create after Phase 1:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-url-transformer.md`
Adds domain mapping, URL replacement in post content, URL replacement inside serialized metadata, and GUID/permalink transformation rules.
**Exit Criteria:**
- Plain text URLs, HTML attributes, JSON strings, and serialized PHP arrays are transformed safely.
- Serialized data remains valid after replacement.
- Mapping supports multiple source/destination pairs.
- Tests cover escaped URLs, protocol-relative URLs, and nested metadata.
## Phase 3: Content Package Schema and File Transport
**Plan to create after Phase 2:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md`
Defines the sync package schema and implements export/import through JSON files for posts, pages, taxonomies, media metadata, and custom post type records.
**Exit Criteria:**
- Export produces a versioned JSON package with manifest, content records, taxonomy records, media records, and checksum metadata.
- Import validates schema before mutating data.
- Invalid files produce actionable admin errors without partial writes.
- File import uses WordPress nonce and capability checks.
## Phase 4: REST Transport
**Plan to create after Phase 3:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-rest-transport.md`
Adds authenticated REST endpoints and REST client support using WordPress application passwords.
**Exit Criteria:**
- Destination exposes secure receive/status endpoints.
- Source can test connection and send packages.
- REST failures surface typed errors and can fall back to file transport.
- Endpoint mutations validate permissions and request shape.
## Phase 5: Sync Engine and Content Handlers
**Plan to create after Phase 4:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-engine-handlers.md`
Implements orchestration, content extraction/import handlers, conflict detection, retries, progress state, and operation logs.
**Exit Criteria:**
- Push and pull operations can sync posts, pages, custom post types, taxonomies, metadata, and media records.
- Last-write-wins conflict behavior is logged.
- Partial failures preserve operation state.
- Long-running work is chunked to avoid memory exhaustion.
## Phase 6: Admin Workflow and Hardening
**Plan to create after Phase 5:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md`
Completes the admin UI, connection diagnostics, operation history, import/export screens, user-facing errors, debug logging controls, and smoke/integration tests.
**Exit Criteria:**
- Users can configure sync pairs, credentials, URL mappings, content type selection, and sync direction.
- Every state-changing admin action verifies nonce and capability.
- Output is escaped and input is sanitized.
- Manual smoke checklist covers production-to-staging, staging-to-production, REST, and file fallback.
## Cross-Phase Standards
- Keep implementation files under `src/` with namespace `WPContentSync`.
- Keep tests under `tests/Unit` and `tests/Integration`.
- Keep admin templates under `templates/admin`.
- Use `wpcs_` as the option/transient/action prefix for this plugin.
- Prefer interfaces at boundaries: settings storage, logger, transport, handlers, package validation.
- Never store raw application passwords in logs.
- Never mutate WordPress content from an unauthenticated request.
- Every plan should include test-first steps and exact verification commands.
@@ -0,0 +1,293 @@
# WordPress Content Sync Plugin Design
**Date:** 2026-04-25
**Type:** Feature Design
**Status:** Approved
## Overview
A WordPress plugin that enables bidirectional content synchronization between two WordPress instances, supporting both REST API and file-based transfer methods. The plugin handles all content types including posts, pages, custom post types, taxonomies, and media, with automatic URL rewriting for domain changes.
## Use Case
Primary use case is syncing content between production and staging/development environments for developers building and maintaining WordPress sites. The plugin must work with instances that are not publicly accessible and where git or wp-cli are unavailable.
## Architecture
### Core Layers
**Core Sync Engine**
- Orchestrates sync operations between instances
- Manages both REST API and file transfer methods
- Coordinates source and destination operations
- Handles error recovery and retry logic
**Content Handlers**
- Separate modules for different content types
- Each handler extracts, transforms, and restores specific content
- Supports posts, pages, media, custom post types, taxonomies, metadata
**URL Transformation Layer**
- Handles all URL rewriting during sync operations
- Supports automatic domain mapping and manual overrides
- Processes content links, image paths, and custom field URLs
**Transport Layer**
- Abstracts communication method between instances
- REST API implementation with application password authentication
- File transfer implementation as fallback
**Admin Interface**
- WordPress admin pages for configuration
- Sync operation management and monitoring
- Status reporting and error handling
## Core Components
### Sync Manager
Central orchestrator responsible for:
- Managing sync configuration (instance pairs, credentials, URL mappings)
- Determining transport method (REST API vs file transfer)
- Coordinating content handlers during sync operations
- Handling error recovery and retry logic
- Providing sync status and progress reporting
### Content Handlers
Specialized processors for different content types:
**Posts/Pages Handler**
- Standard WordPress content with revisions
- Handles post content, excerpts, titles, and status
- Manages post relationships and hierarchy
**Media Handler**
- File uploads and attachments
- URL rewriting for media references
- Handles image sizes and metadata
**Custom Post Types Handler**
- Dynamic CPT support
- Respects CPT registration and capabilities
- Handles CPT-specific metadata
**Taxonomies Handler**
- Categories, tags, and custom taxonomies
- Term relationships and hierarchies
- Term metadata and descriptions
**Metadata Handler**
- Post meta, user meta, and options
- Serialized data handling
- Custom field URL scanning
**Users Handler** (Optional)
- User accounts and roles
- User capabilities and permissions
- User metadata
### URL Transformer
Handles URL transformation during sync:
- Domain mapping configuration (source → destination URLs)
- Content body URL replacement (links, images, embedded content)
- GUID and permalink updates
- Custom field URL scanning
- Serialized data URL replacement
### Transport Abstraction
Two implementation strategies:
**REST API Transport**
- Uses WordPress REST API endpoints
- Application password authentication
- Automatic when instances can communicate
- Supports real-time sync operations
**File Transport**
- Generates JSON packages with all content data
- Manual file transfer between instances
- Fallback when REST API unavailable
- Supports offline sync scenarios
## Data Flow
### Push Operation (Source → Destination)
1. User initiates push from source instance admin
2. Sync Manager validates destination connection
3. Content handlers extract data from source database
4. URL Transformer rewrites source URLs to destination URLs
5. Transport layer sends data (REST API or generates file)
6. Destination receives and processes data
7. Content handlers import/merge data on destination
8. Sync Manager reports completion status
### Pull Operation (Destination ← Source)
1. User initiates pull from destination instance admin
2. Destination connects to source instance
3. Source extracts and transforms data
4. Source sends data to destination
5. Destination processes and imports data
6. Sync Manager reports completion status
### File Transfer Flow
1. Source instance exports data to JSON file
2. User manually transfers file between instances
3. Destination instance imports JSON file
4. Same processing as REST API flow
### Conflict Resolution
- Last-write-wins strategy for content conflicts
- Optional merge strategies for specific content types
- Conflict log for review after sync operations
- User notification of conflicts requiring manual resolution
## Error Handling
### Connection Errors
- Automatic retry with exponential backoff for REST API failures
- Graceful fallback to file transfer if REST API unavailable
- Clear error messages with troubleshooting steps
- Connection timeout handling
### Data Validation
- Schema validation for imported JSON files
- Content integrity checks during sync operations
- Validation of required fields and relationships
- Data type and format verification
### Partial Failure Recovery
- Transaction-based sync operations with rollback on failure
- Progress tracking for resumable sync operations
- Detailed error logs with context for debugging
- State preservation for interrupted operations
### User-Facing Error Messages
- Specific error types (connection, authentication, data, transport)
- Actionable suggestions for each error type
- Error severity levels (warning, error, critical)
- Contextual help and documentation links
### Logging and Debugging
- Detailed sync operation logs
- Performance metrics for optimization
- Optional debug mode for troubleshooting
- Log rotation and management
## Admin Interface
### Main Dashboard
- List of configured sync pairs (source/destination instances)
- Quick sync status indicators (last sync time, success/failure)
- Push/Pull action buttons for each sync pair
- Sync history and logs viewer
- Overall system health indicators
### Configuration Pages
**Sync Pair Setup**
- Add/edit source and destination instances
- Instance naming and identification
- Connection details and credentials
**Connection Test**
- Verify REST API connectivity
- Test authentication credentials
- Validate URL mappings
- Network diagnostics
**URL Mapping Configuration**
- Define source/destination domain mappings
- Multiple domain support
- URL pattern matching
- Custom replacement rules
**Content Type Selection**
- Choose which content types to sync
- Individual content type toggles
- Bulk selection options
- Custom post type detection
**Authentication Setup**
- Application passwords and credentials
- Secure credential storage
- Credential rotation support
- Permission verification
### Sync Operation Interface
- Progress bar with real-time status updates
- Content type breakdown (posts, media, etc.)
- Error/warning notifications during sync
- Cancel operation support
- Detailed results summary after completion
### Settings and Preferences
- Default sync direction preferences
- Conflict resolution strategy selection
- Automatic URL replacement toggle
- Logging level configuration
- Performance tuning options
## Testing Strategy
### Unit Testing
- Content handler tests for each content type
- URL transformer tests with various URL patterns
- Transport layer tests for both REST API and file methods
- Sync manager orchestration tests
- Individual component isolation tests
### Integration Testing
- End-to-end sync operations between test instances
- REST API authentication and communication
- File export/import functionality
- URL rewriting accuracy across different content types
- Multi-instance sync scenarios
### Edge Case Testing
- Large content sets (performance testing)
- Special characters and encoding issues
- Custom post types and taxonomies
- Plugin/theme compatibility
- Network connectivity issues
- Concurrent sync operations
### Manual Testing Scenarios
- Production to staging sync
- Staging to production sync
- File transfer fallback
- URL replacement accuracy
- Conflict resolution behavior
- Error recovery procedures
## Technical Requirements
### WordPress Compatibility
- Minimum WordPress version: 5.6+
- PHP version: 7.4+
- REST API support required
- Application passwords feature required for REST API method
### Performance Considerations
- Support for large content sets (1000+ posts)
- Efficient memory usage during sync operations
- Background processing for long-running operations
- Progress tracking for user feedback
### Security Considerations
- Secure credential storage
- Application password authentication
- Data encryption for file transfers
- Permission validation
- Audit logging for sync operations
## Success Criteria
1. Successfully sync all content types between two WordPress instances
2. Handle URL rewriting accurately for domain changes
3. Support both REST API and file transfer methods
4. Provide clear error messages and recovery options
5. Work with non-publicly accessible instances
6. Function without git or wp-cli dependencies
7. Support bidirectional sync with push/pull operations
8. Handle custom post types and taxonomies dynamically
9. Provide comprehensive admin interface for configuration
10. Include robust error handling and logging
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0"?>
<ruleset name="WP Content Sync">
<description>Custom coding standards for WP Content Sync.</description>
<arg name="basepath" value="."/>
<arg name="extensions" value="php"/>
<arg name="parallel" value="8"/>
<file>wp-content-sync.php</file>
<file>src</file>
<file>tests</file>
<rule ref="WordPress">
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase"/>
<exclude name="WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid"/>
<exclude name="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound"/>
<exclude name="WordPress.Security.EscapeOutput.ExceptionNotEscaped"/>
</rule>
<rule ref="WordPress-Docs">
<exclude name="Squiz.Commenting.ClassComment.Missing"/>
<exclude name="Squiz.Commenting.FileComment.Missing"/>
<exclude name="Squiz.Commenting.FunctionComment.Missing"/>
<exclude name="Squiz.Commenting.FunctionComment.IncorrectTypeHint"/>
<exclude name="Squiz.Commenting.FunctionComment.MissingParamComment"/>
<exclude name="Squiz.Commenting.FunctionComment.MissingParamTag"/>
<exclude name="Squiz.Commenting.FunctionComment.ParamNameNoMatch"/>
<exclude name="Squiz.Commenting.FunctionCommentThrowTag.Missing"/>
<exclude name="Squiz.Commenting.VariableComment.Missing"/>
<exclude name="Generic.Commenting.DocComment.MissingShort"/>
</rule>
<rule ref="WordPress-Extra"/>
<config name="minimum_supported_wp_version" value="5.6"/>
<config name="testVersion" value="7.4-"/>
<exclude-pattern>vendor/*</exclude-pattern>
</ruleset>
+10
View File
@@ -0,0 +1,10 @@
parameters:
level: 6
paths:
- src
- wp-content-sync.php
scanFiles:
- vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
- tests/bootstrap.php
bootstrapFiles:
- tests/bootstrap.php
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true" beStrictAboutTestsThatDoNotTestAnything="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
+19
View File
@@ -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 );
}
}
}
+93
View File
@@ -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';
}
}
+45
View File
@@ -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 ) );
}
}
+14
View File
@@ -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' );
}
}
+25
View File
@@ -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;
}
+106
View File
@@ -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;
}
}
+58
View File
@@ -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();
}
}
+148
View File
@@ -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;
}
}
+25
View File
@@ -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();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Admin dashboard template.
*
* @package WPContentSync
*
* @var \WPContentSync\Settings\Settings $settings
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<div class="wrap">
<h1><?php echo esc_html__( 'WP Content Sync', 'wp-content-sync' ); ?></h1>
<p><?php echo esc_html__( 'Configure sync pairs, test connections, and run push or pull operations from this screen.', 'wp-content-sync' ); ?></p>
<div class="notice notice-info">
<p>
<?php
echo esc_html__(
'The foundation is installed. Sync pair management, URL mappings, file transport, and REST transport will be added in upcoming implementation phases.',
'wp-content-sync'
);
?>
</p>
</div>
<h2><?php echo esc_html__( 'Current Defaults', 'wp-content-sync' ); ?></h2>
<table class="widefat striped">
<tbody>
<tr>
<th scope="row"><?php echo esc_html__( 'Configured Sync Pairs', 'wp-content-sync' ); ?></th>
<td><?php echo esc_html( (string) count( $settings->syncPairs() ) ); ?></td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'Logging Level', 'wp-content-sync' ); ?></th>
<td><?php echo esc_html( $settings->loggingLevel() ); ?></td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'URL Replacement', 'wp-content-sync' ); ?></th>
<td>
<?php
echo esc_html(
$settings->automaticUrlReplacementEnabled()
? __( 'Enabled', 'wp-content-sync' )
: __( 'Disabled', 'wp-content-sync' )
);
?>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'Conflict Strategy', 'wp-content-sync' ); ?></th>
<td><?php echo esc_html( $settings->conflictStrategy() ); ?></td>
</tr>
</tbody>
</table>
</div>
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase;
use WPContentSync\Container;
class ContainerTest extends TestCase {
public function test_it_returns_registered_service(): void {
$container = new Container();
$service = new \stdClass();
$container->set( 'example', $service );
self::assertSame( $service, $container->get( 'example' ) );
}
public function test_it_reuses_factory_result(): void {
$container = new Container();
$calls = 0;
$container->factory(
'example',
static function () use ( &$calls ): \stdClass {
++$calls;
return new \stdClass();
}
);
$first = $container->get( 'example' );
$second = $container->get( 'example' );
self::assertSame( $first, $second );
self::assertSame( 1, $calls );
}
public function test_it_throws_for_unknown_service(): void {
$container = new Container();
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'Service "missing" is not registered.' );
$container->get( 'missing' );
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase;
use WPContentSync\Logging\OptionLogger;
class OptionLoggerTest extends TestCase {
protected function setUp(): void {
$GLOBALS['wpcs_test_options'] = array();
}
public function test_it_records_log_entries(): void {
$logger = new OptionLogger( 10 );
$logger->warning( 'Connection failed.', array( 'url' => 'https://example.test' ) );
$entries = get_option( OptionLogger::OPTION_NAME, array() );
self::assertCount( 1, $entries );
self::assertSame( 'warning', $entries[0]['level'] );
self::assertSame( 'Connection failed.', $entries[0]['message'] );
self::assertSame( 'https://example.test', $entries[0]['context']['url'] );
self::assertArrayHasKey( 'timestamp', $entries[0] );
}
public function test_it_redacts_sensitive_context_values(): void {
$logger = new OptionLogger( 10 );
$logger->error(
'Authentication failed.',
array(
'application_password' => 'secret-value',
'client_secret' => 'client-secret-value',
'headers' => array(
'Authorization' => 'Bearer nested-token',
),
'token' => 'token-value',
'username' => 'admin',
)
);
$entries = get_option( OptionLogger::OPTION_NAME, array() );
self::assertSame( '[redacted]', $entries[0]['context']['application_password'] );
self::assertSame( '[redacted]', $entries[0]['context']['client_secret'] );
self::assertSame( '[redacted]', $entries[0]['context']['headers']['Authorization'] );
self::assertSame( '[redacted]', $entries[0]['context']['token'] );
self::assertSame( 'admin', $entries[0]['context']['username'] );
}
public function test_it_limits_retained_entries(): void {
$logger = new OptionLogger( 2 );
$logger->info( 'First' );
$logger->info( 'Second' );
$logger->info( 'Third' );
$entries = get_option( OptionLogger::OPTION_NAME, array() );
self::assertCount( 2, $entries );
self::assertSame( 'Second', $entries[0]['message'] );
self::assertSame( 'Third', $entries[1]['message'] );
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase;
use WPContentSync\Settings\Settings;
class SettingsTest extends TestCase {
public function test_it_provides_secure_defaults(): void {
$settings = Settings::fromArray( array() );
self::assertSame( array(), $settings->syncPairs() );
self::assertSame( 'warning', $settings->loggingLevel() );
self::assertTrue( $settings->automaticUrlReplacementEnabled() );
self::assertSame( 'last_write_wins', $settings->conflictStrategy() );
}
public function test_it_sanitizes_scalar_settings(): void {
$settings = Settings::fromArray(
array(
'logging_level' => '<b>debug</b>',
'conflict_strategy' => "manual_review\n",
'automatic_url_replacement' => false,
)
);
self::assertSame( 'debug', $settings->loggingLevel() );
self::assertSame( 'manual_review', $settings->conflictStrategy() );
self::assertFalse( $settings->automaticUrlReplacementEnabled() );
}
public function test_it_rejects_unknown_logging_level(): void {
$settings = Settings::fromArray(
array(
'logging_level' => 'verbose',
)
);
self::assertSame( 'warning', $settings->loggingLevel() );
}
public function test_it_normalizes_string_boolean_values(): void {
$settings = Settings::fromArray(
array(
'automatic_url_replacement' => 'false',
)
);
self::assertFalse( $settings->automaticUrlReplacementEnabled() );
$settings = Settings::fromArray(
array(
'automatic_url_replacement' => '1',
)
);
self::assertTrue( $settings->automaticUrlReplacementEnabled() );
}
public function test_it_serializes_to_array(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
),
),
)
);
self::assertSame(
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
),
),
'logging_level' => 'warning',
'automatic_url_replacement' => true,
'conflict_strategy' => 'last_write_wins',
),
$settings->toArray()
);
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* PHPUnit and PHPStan bootstrap for isolated plugin tests.
*
* @package WPContentSync
*/
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}
if ( ! defined( 'WPCS_PLUGIN_DIR' ) ) {
define( 'WPCS_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
}
if ( ! defined( 'WPCS_PLUGIN_URL' ) ) {
define( 'WPCS_PLUGIN_URL', 'https://example.test/wp-content/plugins/wp-content-sync/' );
}
if ( ! defined( 'WPCS_VERSION' ) ) {
define( 'WPCS_VERSION', '0.1.0' );
}
if ( ! function_exists( 'sanitize_text_field' ) ) {
/**
* Minimal WordPress-compatible text sanitizer for unit tests.
*
* @param mixed $value Value to sanitize.
* @return string
*/
function sanitize_text_field( $value ) {
return trim( preg_replace( '/[\r\n\t]+/', ' ', wp_strip_all_tags( (string) $value ) ) );
}
}
if ( ! function_exists( 'wp_strip_all_tags' ) ) {
/**
* Minimal tag stripper for unit tests.
*
* @param string $value Value to strip.
* @return string
*/
function wp_strip_all_tags( $value ) {
return preg_replace( '/<[^>]*>/', '', $value );
}
}
if ( ! function_exists( 'esc_html' ) ) {
/**
* Minimal HTML escaper for unit tests.
*
* @param mixed $value Value to escape.
* @return string
*/
function esc_html( $value ) {
return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
}
}
if ( ! function_exists( '__' ) ) {
/**
* Minimal translation helper for unit tests.
*
* @param string $text Text to translate.
* @param string $domain Text domain.
* @return string
*/
function __( $text, $domain = 'default' ) {
$GLOBALS['wpcs_test_text_domain'] = $domain;
return $text;
}
}
if ( ! function_exists( 'esc_html__' ) ) {
/**
* Minimal translated HTML escaper for unit tests.
*
* @param string $text Text to translate and escape.
* @param string $domain Text domain.
* @return string
*/
function esc_html__( $text, $domain = 'default' ) {
$GLOBALS['wpcs_test_text_domain'] = $domain;
return esc_html( $text );
}
}
if ( ! function_exists( 'esc_attr' ) ) {
/**
* Minimal attribute escaper for unit tests.
*
* @param mixed $value Value to escape.
* @return string
*/
function esc_attr( $value ) {
return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
}
}
if ( ! function_exists( 'esc_url' ) ) {
/**
* Minimal URL sanitizer for unit tests.
*
* @param mixed $value Value to sanitize.
* @return string
*/
function esc_url( $value ) {
return filter_var( (string) $value, FILTER_SANITIZE_URL );
}
}
if ( ! function_exists( 'esc_url_raw' ) ) {
/**
* Minimal raw URL sanitizer for unit tests.
*
* @param mixed $value Value to sanitize.
* @return string
*/
function esc_url_raw( $value ) {
return filter_var( (string) $value, FILTER_SANITIZE_URL );
}
}
if ( ! function_exists( 'get_option' ) ) {
/**
* Minimal WordPress option reader for unit tests.
*
* @param string $name Option name.
* @param mixed $default_value Default value.
* @return mixed
*/
function get_option( $name, $default_value = false ) {
return $GLOBALS['wpcs_test_options'][ $name ] ?? $default_value;
}
}
if ( ! function_exists( 'update_option' ) ) {
/**
* Minimal WordPress option writer for unit tests.
*
* @param string $name Option name.
* @param mixed $value Option value.
* @param mixed $autoload Autoload flag.
* @return bool
*/
function update_option( $name, $value, $autoload = null ) {
$GLOBALS['wpcs_test_options'][ $name ] = $value;
$GLOBALS['wpcs_test_option_autoloads'][ $name ] = $autoload;
return true;
}
}
if ( ! function_exists( 'delete_transient' ) ) {
/**
* Minimal WordPress transient deleter for unit tests.
*
* @param string $name Transient name.
* @return bool
*/
function delete_transient( $name ) {
unset( $GLOBALS['wpcs_test_transients'][ $name ] );
return true;
}
}
if ( ! function_exists( 'plugin_dir_path' ) ) {
/**
* Minimal plugin path helper for static analysis.
*
* @param string $file Plugin file.
* @return string
*/
function plugin_dir_path( $file ) {
return trailingslashit( dirname( $file ) );
}
}
if ( ! function_exists( 'plugin_dir_url' ) ) {
/**
* Minimal plugin URL helper for static analysis.
*
* @param string $file Plugin file.
* @return string
*/
function plugin_dir_url( $file ) {
return 'http://example.org/wp-content/plugins/' . basename( dirname( $file ) ) . '/';
}
}
if ( ! function_exists( 'trailingslashit' ) ) {
/**
* Minimal trailing slash helper for static analysis.
*
* @param string $value Value to slash.
* @return string
*/
function trailingslashit( $value ) {
return rtrim( $value, '/\\' ) . DIRECTORY_SEPARATOR;
}
}
if ( ! function_exists( 'register_activation_hook' ) ) {
/**
* Minimal activation hook registrar for static analysis.
*
* @param string $file Plugin file.
* @param callable $callback Activation callback.
* @return void
*/
function register_activation_hook( $file, $callback ) {
$GLOBALS['wpcs_test_activation_hooks'][ $file ] = $callback;
}
}
if ( ! function_exists( 'register_deactivation_hook' ) ) {
/**
* Minimal deactivation hook registrar for static analysis.
*
* @param string $file Plugin file.
* @param callable $callback Deactivation callback.
* @return void
*/
function register_deactivation_hook( $file, $callback ) {
$GLOBALS['wpcs_test_deactivation_hooks'][ $file ] = $callback;
}
}
if ( ! function_exists( 'add_action' ) ) {
/**
* Minimal action registrar for static analysis.
*
* @param string $hook_name Hook name.
* @param callable $callback Hook callback.
* @return bool
*/
function add_action( $hook_name, $callback ) {
$GLOBALS['wpcs_test_actions'][ $hook_name ][] = $callback;
return true;
}
}
if ( ! function_exists( 'add_management_page' ) ) {
/**
* Minimal management page registrar for static analysis.
*
* @param string $page_title Page title.
* @param string $menu_title Menu title.
* @param string $capability Capability required.
* @param string $menu_slug Menu slug.
* @param callable $callback Page callback.
* @return string
*/
function add_management_page( $page_title, $menu_title, $capability, $menu_slug, $callback ) {
$GLOBALS['wpcs_test_admin_pages'][ $menu_slug ] = array(
'page_title' => $page_title,
'menu_title' => $menu_title,
'capability' => $capability,
'callback' => $callback,
);
return $menu_slug;
}
}
if ( ! function_exists( 'register_setting' ) ) {
/**
* Minimal setting registrar for static analysis.
*
* @param string $option_group Option group.
* @param string $option_name Option name.
* @param array<string, mixed> $args Setting arguments.
* @return bool
*/
function register_setting( $option_group, $option_name, $args = array() ) {
$GLOBALS['wpcs_test_registered_settings'][ $option_name ] = array(
'option_group' => $option_group,
'args' => $args,
);
return true;
}
}
if ( ! function_exists( 'current_user_can' ) ) {
/**
* Minimal capability checker for static analysis.
*
* @param string $capability Capability to check.
* @return bool
*/
function current_user_can( $capability ) {
return 'manage_options' === $capability;
}
}
if ( ! function_exists( 'wp_die' ) ) {
/**
* Minimal WordPress die handler for unit tests.
*
* @param mixed $message Message to die with.
* @return void
*
* @throws \RuntimeException Always throws with the provided message.
*/
function wp_die( $message ) {
throw new \RuntimeException( esc_html( $message ) );
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* Plugin Name: WP Content Sync
* Description: Bidirectional content synchronization between WordPress instances.
* Version: 0.1.0
* Requires at least: 5.6
* Requires PHP: 7.4
* Author: WP Content Sync Contributors
* License: GPL-2.0-or-later
* Text Domain: wp-content-sync
*
* @package WPContentSync
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WPCS_PLUGIN_FILE', __FILE__ );
define( 'WPCS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WPCS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'WPCS_VERSION', '0.1.0' );
$wpcs_autoload = __DIR__ . '/vendor/autoload.php';
if ( ! file_exists( $wpcs_autoload ) ) {
add_action(
'admin_notices',
static function (): void {
printf(
'<div class="notice notice-error"><p>%s</p></div>',
esc_html__( 'WP Content Sync dependencies are missing. Run composer install before activating the plugin.', 'wp-content-sync' )
);
}
);
return;
}
require_once $wpcs_autoload;
register_activation_hook( __FILE__, array( \WPContentSync\Activator::class, 'activate' ) );
register_deactivation_hook( __FILE__, array( \WPContentSync\Deactivator::class, 'deactivate' ) );
add_action(
'plugins_loaded',
static function (): void {
$plugin = \WPContentSync\Plugin::create();
$plugin->register();
}
);