466 lines
13 KiB
PHP
Executable File
466 lines
13 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Lightweight local WordPress emulation for executing plugin code in code/.
|
|
*/
|
|
|
|
final class WP_Error
|
|
{
|
|
public function __construct(
|
|
public string $code,
|
|
public string $message,
|
|
public array $data = []
|
|
) {
|
|
}
|
|
}
|
|
|
|
final class WP_REST_Request
|
|
{
|
|
private array $params = [];
|
|
private array $headers = [];
|
|
|
|
public function __construct(
|
|
private readonly string $method,
|
|
private readonly string $route,
|
|
private readonly array $jsonParams = [],
|
|
array $headers = []
|
|
) {
|
|
$this->params = $jsonParams;
|
|
foreach ($headers as $k => $v) {
|
|
$this->headers[strtolower((string) $k)] = (string) $v;
|
|
}
|
|
}
|
|
|
|
public function get_method(): string
|
|
{
|
|
return $this->method;
|
|
}
|
|
|
|
public function get_route(): string
|
|
{
|
|
return $this->route;
|
|
}
|
|
|
|
public function get_json_params(): array
|
|
{
|
|
return $this->jsonParams;
|
|
}
|
|
|
|
public function set_param(string $key, mixed $value): void
|
|
{
|
|
$this->params[$key] = $value;
|
|
}
|
|
|
|
public function get_param(string $key): mixed
|
|
{
|
|
return $this->params[$key] ?? null;
|
|
}
|
|
|
|
public function get_header(string $name): string
|
|
{
|
|
return $this->headers[strtolower($name)] ?? '';
|
|
}
|
|
}
|
|
|
|
final class WPDB_Compat
|
|
{
|
|
public string $prefix = 'wp_';
|
|
public int $insert_id = 0;
|
|
|
|
/** @var array<string, array<int, array<string, mixed>>> */
|
|
private array $tables = [];
|
|
|
|
/** @var array<string, int> */
|
|
private array $autoIds = [];
|
|
|
|
public function __construct(string $dbPath)
|
|
{
|
|
// In-memory emulation; path retained for compatibility.
|
|
}
|
|
|
|
public function get_charset_collate(): string
|
|
{
|
|
return '';
|
|
}
|
|
|
|
public function prepare(string $query, mixed ...$args): string
|
|
{
|
|
$out = $query;
|
|
foreach ($args as $arg) {
|
|
$replacement = is_int($arg) ? (string) $arg : ("'" . str_replace("'", "''", (string) $arg) . "'");
|
|
$out = preg_replace('/%[dsf]/', $replacement, $out, 1) ?? $out;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public function query(string $query): int|false
|
|
{
|
|
$trimmed = trim($query);
|
|
if (preg_match('/^CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)/i', $trimmed, $m)) {
|
|
$table = $m[1];
|
|
$this->tables[$table] = $this->tables[$table] ?? [];
|
|
$this->autoIds[$table] = $this->autoIds[$table] ?? 0;
|
|
return 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
public function get_results(string $query): array
|
|
{
|
|
$q = trim($query);
|
|
|
|
if (preg_match('/^SELECT \* FROM\s+([a-zA-Z0-9_]+)\s+ORDER BY\s+id\s+ASC$/i', $q, $m)) {
|
|
$rows = $this->tables[$m[1]] ?? [];
|
|
usort($rows, static fn(array $a, array $b): int => ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0)));
|
|
return array_map(static fn(array $r): object => (object) $r, $rows);
|
|
}
|
|
|
|
if (preg_match('/^SELECT\s+\*\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+id\s*=\s*(\d+)$/i', $q, $m)) {
|
|
$row = $this->findById($m[1], (int) $m[2]);
|
|
return $row ? [(object) $row] : [];
|
|
}
|
|
|
|
if (preg_match('/^SELECT\s+\*\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+caldav_resource\s*=\s*\'([^\']*)\'$/i', $q, $m)) {
|
|
$table = $m[1];
|
|
$needle = str_replace("''", "'", $m[2]);
|
|
$rows = $this->tables[$table] ?? [];
|
|
foreach ($rows as $row) {
|
|
if ((string) ($row['caldav_resource'] ?? '') === $needle) {
|
|
return [(object) $row];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
if (preg_match('/^SELECT\s+occurrence_key\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+event_id\s*=\s*(\d+)\s+AND\s+exception_type\s*=\s*\'([^\']+)\'$/i', $q, $m)) {
|
|
$table = $m[1];
|
|
$eventId = (int) $m[2];
|
|
$type = $m[3];
|
|
$rows = $this->tables[$table] ?? [];
|
|
$filtered = array_values(array_filter(
|
|
$rows,
|
|
static fn(array $r): bool => ((int) ($r['event_id'] ?? 0) === $eventId) && ((string) ($r['exception_type'] ?? '') === $type)
|
|
));
|
|
return array_map(
|
|
static fn(array $r): object => (object) ['occurrence_key' => (string) ($r['occurrence_key'] ?? '')],
|
|
$filtered
|
|
);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
public function get_row(string $query): ?object
|
|
{
|
|
$rows = $this->get_results($query);
|
|
return $rows[0] ?? null;
|
|
}
|
|
|
|
public function get_var(string $query): mixed
|
|
{
|
|
$q = trim($query);
|
|
if (preg_match('/^SHOW TABLES LIKE\s*\'([^\']+)\'$/i', $q, $m)) {
|
|
$table = str_replace("''", "'", $m[1]);
|
|
return array_key_exists($table, $this->tables) ? $table : null;
|
|
}
|
|
$row = $this->get_row($query);
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
$vars = get_object_vars($row);
|
|
foreach ($vars as $value) {
|
|
return $value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function insert(string $table, array $data, array $formats = []): int|false
|
|
{
|
|
$this->tables[$table] = $this->tables[$table] ?? [];
|
|
$this->autoIds[$table] = $this->autoIds[$table] ?? 0;
|
|
|
|
$id = ++$this->autoIds[$table];
|
|
$row = ['id' => $id] + $data;
|
|
$this->tables[$table][] = $row;
|
|
$this->insert_id = $id;
|
|
|
|
return 1;
|
|
}
|
|
|
|
public function update(string $table, array $data, array $where, array $formats = [], array $whereFormats = []): int|false
|
|
{
|
|
$rows = $this->tables[$table] ?? [];
|
|
$updated = 0;
|
|
foreach ($rows as $i => $row) {
|
|
if (!$this->matchesWhere($row, $where)) {
|
|
continue;
|
|
}
|
|
$rows[$i] = array_merge($row, $data);
|
|
$updated++;
|
|
}
|
|
$this->tables[$table] = $rows;
|
|
return $updated;
|
|
}
|
|
|
|
public function delete(string $table, array $where, array $whereFormats = []): int|false
|
|
{
|
|
$rows = $this->tables[$table] ?? [];
|
|
$kept = [];
|
|
$deleted = 0;
|
|
foreach ($rows as $row) {
|
|
if ($this->matchesWhere($row, $where)) {
|
|
$deleted++;
|
|
continue;
|
|
}
|
|
$kept[] = $row;
|
|
}
|
|
$this->tables[$table] = $kept;
|
|
return $deleted;
|
|
}
|
|
|
|
private function findById(string $table, int $id): ?array
|
|
{
|
|
$rows = $this->tables[$table] ?? [];
|
|
foreach ($rows as $row) {
|
|
if ((int) ($row['id'] ?? 0) === $id) {
|
|
return $row;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function matchesWhere(array $row, array $where): bool
|
|
{
|
|
foreach ($where as $k => $v) {
|
|
if (!array_key_exists($k, $row)) {
|
|
return false;
|
|
}
|
|
if ((string) $row[$k] !== (string) $v) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$GLOBALS['wp_actions'] = [];
|
|
$GLOBALS['wp_shortcodes'] = [];
|
|
$GLOBALS['wp_routes'] = [];
|
|
$GLOBALS['wp_options'] = [];
|
|
$GLOBALS['wp_mail_outbox'] = [];
|
|
$GLOBALS['wp_user'] = [
|
|
'id' => 1,
|
|
'email' => 'admin@example.test',
|
|
'caps' => ['manage_options', 'edit_posts'],
|
|
];
|
|
$GLOBALS['wp_activation_hooks'] = [];
|
|
$GLOBALS['wp_admin_menu'] = [];
|
|
$GLOBALS['wp_options']['admin_email'] = 'admin@example.test';
|
|
|
|
function add_action(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void
|
|
{
|
|
$GLOBALS['wp_actions'][$hook][] = $callback;
|
|
}
|
|
|
|
function do_action(string $hook, mixed ...$args): void
|
|
{
|
|
foreach (($GLOBALS['wp_actions'][$hook] ?? []) as $cb) {
|
|
$cb(...$args);
|
|
}
|
|
}
|
|
|
|
function add_shortcode(string $tag, callable $callback): void
|
|
{
|
|
$GLOBALS['wp_shortcodes'][$tag] = $callback;
|
|
}
|
|
|
|
function do_shortcode(string $content): string
|
|
{
|
|
if (preg_match('/\[([a-z0-9_\-]+)\]/i', $content, $m)) {
|
|
$tag = $m[1];
|
|
if (isset($GLOBALS['wp_shortcodes'][$tag])) {
|
|
return (string) call_user_func($GLOBALS['wp_shortcodes'][$tag]);
|
|
}
|
|
}
|
|
return $content;
|
|
}
|
|
|
|
function register_rest_route(string $namespace, string $route, array $args): void
|
|
{
|
|
$GLOBALS['wp_routes'][] = [
|
|
'namespace' => '/' . trim($namespace, '/'),
|
|
'route' => $route,
|
|
'args' => $args,
|
|
];
|
|
}
|
|
|
|
function rest_do_request(WP_REST_Request $request): array|WP_Error
|
|
{
|
|
$method = strtoupper($request->get_method());
|
|
$path = '/' . trim($request->get_route(), '/');
|
|
|
|
foreach ($GLOBALS['wp_routes'] as $entry) {
|
|
$namespace = $entry['namespace'];
|
|
$routePattern = $entry['route'];
|
|
$args = $entry['args'];
|
|
|
|
$registeredMethods = strtoupper((string) ($args['methods'] ?? 'GET'));
|
|
if ($registeredMethods !== $method) {
|
|
continue;
|
|
}
|
|
|
|
$regex = '#^' . preg_quote($namespace, '#') . preg_replace('#/#', '\\/', $routePattern) . '$#';
|
|
if (!preg_match($regex, $path, $matches)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($matches as $k => $v) {
|
|
if (is_string($k)) {
|
|
$request->set_param($k, $v);
|
|
}
|
|
}
|
|
|
|
$perm = $args['permission_callback'] ?? '__return_true';
|
|
$allowed = is_callable($perm) ? (bool) call_user_func($perm, $request) : false;
|
|
if (!$allowed) {
|
|
return new WP_Error('forbidden', 'Forbidden', ['status' => 403]);
|
|
}
|
|
|
|
$cb = $args['callback'] ?? null;
|
|
if (!is_callable($cb)) {
|
|
return new WP_Error('server_error', 'Route callback missing', ['status' => 500]);
|
|
}
|
|
|
|
return call_user_func($cb, $request);
|
|
}
|
|
|
|
return new WP_Error('not_found', 'Route not found', ['status' => 404]);
|
|
}
|
|
|
|
function register_activation_hook(string $pluginFile, callable $callback): void
|
|
{
|
|
$GLOBALS['wp_activation_hooks'][$pluginFile] = $callback;
|
|
}
|
|
|
|
function register_deactivation_hook(string $pluginFile, callable $callback): void
|
|
{
|
|
$GLOBALS['wp_activation_hooks'][$pluginFile . ':deactivate'] = $callback;
|
|
}
|
|
|
|
function get_option(string $key, mixed $default = false): mixed
|
|
{
|
|
return array_key_exists($key, $GLOBALS['wp_options']) ? $GLOBALS['wp_options'][$key] : $default;
|
|
}
|
|
|
|
function update_option(string $key, mixed $value, bool $autoload = true): bool
|
|
{
|
|
$GLOBALS['wp_options'][$key] = $value;
|
|
return true;
|
|
}
|
|
|
|
function delete_option(string $key): bool
|
|
{
|
|
unset($GLOBALS['wp_options'][$key]);
|
|
return true;
|
|
}
|
|
|
|
function get_current_user_id(): int
|
|
{
|
|
return (int) ($GLOBALS['wp_user']['id'] ?? 0);
|
|
}
|
|
|
|
function current_user_can(string $capability): bool
|
|
{
|
|
return in_array($capability, (array) ($GLOBALS['wp_user']['caps'] ?? []), true);
|
|
}
|
|
|
|
function wp_verify_nonce(string $nonce, string $action): bool
|
|
{
|
|
return $nonce === 'ok:' . $action;
|
|
}
|
|
|
|
function wp_get_current_user(): object
|
|
{
|
|
return (object) ['user_email' => (string) ($GLOBALS['wp_user']['email'] ?? '')];
|
|
}
|
|
|
|
function esc_html(string $value): string
|
|
{
|
|
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
}
|
|
|
|
function __return_true(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!function_exists('dbDelta')) {
|
|
function dbDelta(string $sql): array
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function add_menu_page(string $pageTitle, string $menuTitle, string $capability, string $menuSlug, callable $callback): void
|
|
{
|
|
$GLOBALS['wp_admin_menu'][$menuSlug] = ['title' => $menuTitle, 'callback' => $callback];
|
|
}
|
|
|
|
function add_submenu_page(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $menuSlug, callable $callback): void
|
|
{
|
|
$GLOBALS['wp_admin_menu'][$menuSlug] = ['title' => $menuTitle, 'parent' => $parentSlug, 'callback' => $callback];
|
|
}
|
|
|
|
function admin_url(string $path = ''): string
|
|
{
|
|
return '/wp-admin/' . ltrim($path, '/');
|
|
}
|
|
|
|
function home_url(string $path = ''): string
|
|
{
|
|
return 'http://localhost' . (str_starts_with($path, '/') ? $path : ('/' . $path));
|
|
}
|
|
|
|
function wp_mail(string|array $to, string $subject, string $message): bool
|
|
{
|
|
$GLOBALS['wp_mail_outbox'][] = ['to' => $to, 'subject' => $subject, 'message' => $message];
|
|
return true;
|
|
}
|
|
|
|
function wp_generate_password(int $length = 12, bool $specialChars = true): string
|
|
{
|
|
$bytes = random_bytes(max(1, intdiv($length + 1, 2)));
|
|
return substr(bin2hex($bytes), 0, $length);
|
|
}
|
|
|
|
if (!defined('ABSPATH')) {
|
|
define('ABSPATH', __DIR__ . '/wp-emu-root/');
|
|
define('CALENDAR_PLUGIN_ALLOW_DEBUG_TOKENS', true);
|
|
}
|
|
|
|
$upgradePath = ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
@mkdir(dirname($upgradePath), 0775, true);
|
|
file_put_contents(
|
|
$upgradePath,
|
|
"<?php\nif (!function_exists('dbDelta')) { function dbDelta(string \$sql): array { return []; } }\n"
|
|
);
|
|
|
|
$wpdb = new WPDB_Compat(__DIR__ . '/wp_emulation.db');
|
|
$GLOBALS['wpdb'] = $wpdb;
|
|
|
|
$wpdb->query('CREATE TABLE IF NOT EXISTS wp_calendar_events (id INTEGER PRIMARY KEY AUTOINCREMENT)');
|
|
$wpdb->query('CREATE TABLE IF NOT EXISTS wp_calendar_recurrence_exceptions (id INTEGER PRIMARY KEY AUTOINCREMENT)');
|
|
|
|
require_once dirname(__DIR__) . '/code/calendar-plugin.php';
|
|
|
|
do_action('init');
|
|
do_action('rest_api_init');
|
|
|
|
if (PHP_SAPI === 'cli' && basename((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === 'wp_emulation.php') {
|
|
echo "wp-emulation booted\n";
|
|
echo 'routes=' . count($GLOBALS['wp_routes']) . "\n";
|
|
}
|