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