feat: scaffold plugin foundation
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
/.phpunit.result.cache
|
||||
/.vscode/
|
||||
@@ -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
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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user