initial
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Database;
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class DbException extends \RuntimeException {}
|
||||
|
||||
class Db {
|
||||
|
||||
public static function query(string $sql, array $params = []): int {
|
||||
global $wpdb;
|
||||
$result = $wpdb->query(empty($params) ? $sql : $wpdb->prepare($sql, $params));
|
||||
if ($result === false) {
|
||||
self::fail($wpdb->last_error ?: 'Query failed');
|
||||
}
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
public static function get_row(string $sql, array $params = [], string $output = OBJECT): object|array|null {
|
||||
global $wpdb;
|
||||
$query = empty($params) ? $sql : $wpdb->prepare($sql, $params);
|
||||
$row = $wpdb->get_row($query, $output);
|
||||
self::throw_if_error();
|
||||
return $row;
|
||||
}
|
||||
|
||||
public static function get_results(string $sql, array $params = [], string $output = OBJECT): array {
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results(empty($params) ? $sql : $wpdb->prepare($sql, $params), $output);
|
||||
self::throw_if_error();
|
||||
return $rows ?? [];
|
||||
}
|
||||
|
||||
public static function get_var(string $sql, array $params = []): ?string {
|
||||
global $wpdb;
|
||||
$value = $wpdb->get_var(empty($params) ? $sql : $wpdb->prepare($sql, $params));
|
||||
self::throw_if_error();
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function get_col(string $sql, array $params = []): array {
|
||||
global $wpdb;
|
||||
$col = $wpdb->get_col(empty($params) ? $sql : $wpdb->prepare($sql, $params));
|
||||
self::throw_if_error();
|
||||
return $col ?? [];
|
||||
}
|
||||
|
||||
public static function insert(string $table, array $data, array|string|null $format = null): int {
|
||||
global $wpdb;
|
||||
$result = $wpdb->insert($table, $data, $format);
|
||||
if ($result === false) {
|
||||
self::fail($wpdb->last_error ?: 'Insert failed');
|
||||
}
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
public static function update(string $table, array $data, array $where, array|string|null $format = null, array|string|null $where_format = null): int {
|
||||
global $wpdb;
|
||||
$result = $wpdb->update($table, $data, $where, $format, $where_format);
|
||||
if ($result === false) {
|
||||
self::fail($wpdb->last_error ?: 'Update failed');
|
||||
}
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
public static function delete(string $table, array $where, array|string|null $where_format = null): int {
|
||||
global $wpdb;
|
||||
$result = $wpdb->delete($table, $where, $where_format);
|
||||
if ($result === false) {
|
||||
self::fail($wpdb->last_error ?: 'Delete failed');
|
||||
}
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
public static function begin_transaction(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query('START TRANSACTION');
|
||||
}
|
||||
|
||||
public static function commit(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query('COMMIT');
|
||||
}
|
||||
|
||||
public static function rollback(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a named MySQL advisory lock (GET_LOCK). Session-scoped and
|
||||
* independent of transactions, so callers must release it explicitly.
|
||||
* Returns true if the lock was obtained within $timeout seconds.
|
||||
*/
|
||||
public static function acquire_lock(string $name, int $timeout = 10): bool {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $name, $timeout)) === 1;
|
||||
}
|
||||
|
||||
public static function release_lock(string $name): void {
|
||||
global $wpdb;
|
||||
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $name));
|
||||
}
|
||||
|
||||
public static function prefix(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix;
|
||||
}
|
||||
|
||||
public static function last_insert_id(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
public static function charset_collate(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->get_charset_collate();
|
||||
}
|
||||
|
||||
private static function throw_if_error(): void {
|
||||
global $wpdb;
|
||||
if ($wpdb->last_error) {
|
||||
self::fail($wpdb->last_error);
|
||||
}
|
||||
}
|
||||
|
||||
private static function fail(string $message): void {
|
||||
Logger::error('[Db] ' . $message);
|
||||
throw new DbException($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Database Module
|
||||
|
||||
A static wrapper around WordPress's `$wpdb` global that provides a unified interface with consistent error handling.
|
||||
|
||||
**Namespace:** `Reservair\Database`
|
||||
|
||||
## Why
|
||||
|
||||
Direct `$wpdb` usage has two problems: error handling is manual (check `$wpdb->last_error` after every call) and the API is inconsistent (`insert` returns `false|int`, `get_results` returns `null|array`, etc.). `Db` normalises this — all failures throw `DbException`, all collection methods return `array`, and `insert` returns the new row's ID directly.
|
||||
|
||||
The namespace also prevents conflicts with any other plugin that might define a `Db` class in the global scope.
|
||||
|
||||
## Usage
|
||||
|
||||
```php
|
||||
use Reservair\Database\Db;
|
||||
use Reservair\Database\DbException;
|
||||
|
||||
// SELECT — single row
|
||||
$row = Db::get_row('SELECT * FROM %i WHERE id = %d', [$table, $id], ARRAY_A);
|
||||
|
||||
// SELECT — multiple rows
|
||||
$rows = Db::get_results('SELECT * FROM %i WHERE status = %s', [$table, 'active']);
|
||||
|
||||
// SELECT — scalar value
|
||||
$count = Db::get_var('SELECT COUNT(*) FROM %i', [$table]);
|
||||
|
||||
// SELECT — single column
|
||||
$ids = Db::get_col('SELECT id FROM %i WHERE active = 1', [$table]);
|
||||
|
||||
// INSERT — returns new row ID
|
||||
$id = Db::insert(Db::prefix() . 'rsv_reservations', ['name' => 'Alice', 'seats' => 2]);
|
||||
|
||||
// UPDATE — returns number of rows affected
|
||||
$affected = Db::update(Db::prefix() . 'rsv_reservations', ['seats' => 3], ['id' => $id]);
|
||||
|
||||
// DELETE — returns number of rows deleted
|
||||
$deleted = Db::delete(Db::prefix() . 'rsv_reservations', ['id' => $id]);
|
||||
|
||||
// Raw query (DDL, transactions, etc.)
|
||||
Db::query('ALTER TABLE %i ADD COLUMN notes TEXT', [$table]);
|
||||
|
||||
// Transactions
|
||||
try {
|
||||
Db::begin_transaction();
|
||||
Db::insert(...);
|
||||
Db::update(...);
|
||||
Db::commit();
|
||||
} catch (DbException $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
$prefix = Db::prefix(); // e.g. "wp_"
|
||||
$lastId = Db::last_insert_id();
|
||||
$charsetCollate = Db::charset_collate(); // e.g. "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Every method throws `DbException` (extends `\RuntimeException`) on failure. You do not need to check `$wpdb->last_error` manually.
|
||||
|
||||
```php
|
||||
use Reservair\Database\Db;
|
||||
use Reservair\Database\DbException;
|
||||
|
||||
try {
|
||||
Db::insert(Db::prefix() . 'rsv_reservations', $data);
|
||||
} catch (DbException $e) {
|
||||
// $e->getMessage() contains the $wpdb error string
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Method | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `Db::query(sql, params)` | `int` — rows affected | Use for DDL and raw DML |
|
||||
| `Db::get_row(sql, params, output)` | `object\|array\|null` | `null` = no match |
|
||||
| `Db::get_results(sql, params, output)` | `array` | Empty array when no rows |
|
||||
| `Db::get_var(sql, params)` | `?string` | `null` = no match |
|
||||
| `Db::get_col(sql, params)` | `array` | Empty array when no rows |
|
||||
| `Db::insert(table, data, format)` | `int` — new row ID | |
|
||||
| `Db::update(table, data, where, format, where_format)` | `int` — rows affected | |
|
||||
| `Db::delete(table, where, where_format)` | `int` — rows deleted | |
|
||||
| `Db::begin_transaction()` | `void` | |
|
||||
| `Db::commit()` | `void` | |
|
||||
| `Db::rollback()` | `void` | |
|
||||
| `Db::prefix()` | `string` | e.g. `"wp_"` |
|
||||
| `Db::last_insert_id()` | `int` | |
|
||||
| `Db::charset_collate()` | `string` | Use in `CREATE TABLE` DDL |
|
||||
|
||||
All `sql` parameters are passed through `$wpdb->prepare()` when `params` is non-empty.
|
||||
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Forms;
|
||||
|
||||
/**
|
||||
* Fluent builder for WordPress admin settings forms.
|
||||
*
|
||||
* Renders a <table class="form-table"> with one row per field.
|
||||
* Hidden inputs and datalist elements are emitted before the table;
|
||||
* notices before, submit button after.
|
||||
*
|
||||
* Usage:
|
||||
* echo RsvFormBuilder::create()
|
||||
* ->text('name', 'Name', required: true)
|
||||
* ->email('email', 'Email')
|
||||
* ->submit('Save');
|
||||
*/
|
||||
class RsvFormBuilder
|
||||
{
|
||||
private string $form_id = "";
|
||||
|
||||
/** @var string[] Rendered before the table (hidden inputs, datalists). */
|
||||
private array $before = [];
|
||||
|
||||
/** @var string[] WP admin notice banners rendered before the table. */
|
||||
private array $notices = [];
|
||||
|
||||
/** @var string[] <tr> elements inside the table. */
|
||||
private array $rows = [];
|
||||
|
||||
/** @var string[] Rendered after the table (submit button). */
|
||||
private array $after = [];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function create(string $id): static
|
||||
{
|
||||
return new static();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input fields — each becomes a <tr> in the table
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function text(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<input class="regular-text" type="text" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $req . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function email(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = '',
|
||||
?string $list_id = null
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$list = $list_id !== null ? 'list="' . esc_attr($list_id) . '"' : '';
|
||||
$ctrl = '<input class="regular-text" type="email" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $list . ' ' . $req . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function password(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $placeholder = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ph = $placeholder !== '' ? 'placeholder="' . esc_attr($placeholder) . '"' : '';
|
||||
$ctrl = '<input class="regular-text" type="password" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" ' . $ph . ' ' . $req . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function number(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string|int $value = '',
|
||||
?int $min = null,
|
||||
?int $max = null
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$min_attr = $min !== null ? 'min="' . $min . '"' : '';
|
||||
$max_attr = $max !== null ? 'max="' . $max . '"' : '';
|
||||
$ctrl = '<input class="small-text" type="number" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr((string) $value) . '" ' . $min_attr . ' ' . $max_attr . ' ' . $req . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function date(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<input class="regular-text" type="date" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $req . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function time(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<input type="time" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $req . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function checkbox(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $checked = false
|
||||
): static {
|
||||
$c = $checked ? 'checked' : '';
|
||||
$ctrl = '<input type="checkbox" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" ' . $c . '>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, string> $options Associative: value => display text.
|
||||
*/
|
||||
public function select(
|
||||
string $id,
|
||||
string $label,
|
||||
array $options,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $selected = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$opts = $this->build_options($options, $selected);
|
||||
$ctrl = '<select id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" ' . $req . '>' . $opts . '</select>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function textarea(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = '',
|
||||
int $rows = 5
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<textarea class="large-text" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" rows="' . $rows . '" ' . $req . '>'
|
||||
. esc_textarea($value)
|
||||
. '</textarea>';
|
||||
return $this->row($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function custom(string $label, callable $fn) : static {
|
||||
$this->rows[] = '<tr>'
|
||||
. '<th>' . esc_html($label) . '</th>'
|
||||
. '<td>' . $fn() . '</td>'
|
||||
. '</tr>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups multiple inputs into a single row with a shared label.
|
||||
*
|
||||
* The callable receives an RsvFormGroup instance; inputs added to it
|
||||
* are laid out as a flex row inside the row's <td>.
|
||||
*
|
||||
* Example:
|
||||
* ->group('Availability Range', fn($g) => $g
|
||||
* ->time('start_time', 'Start')
|
||||
* ->time('end_time', 'End')
|
||||
* )
|
||||
*/
|
||||
public function group(string $label, callable $fn): static
|
||||
{
|
||||
$group = RsvFormGroup::create();
|
||||
$fn($group);
|
||||
$this->rows[] = '<tr>'
|
||||
. '<th>' . esc_html($label) . '</th>'
|
||||
. '<td>' . $group->render() . '</td>'
|
||||
. '</tr>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Non-field outputs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Hidden input — no row, emitted before the table. */
|
||||
public function hidden(string $id, string|int $value): static
|
||||
{
|
||||
$this->before[] = '<input type="hidden" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr((string) $value) . '">';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* <datalist> element for email/text suggestions — emitted before the table.
|
||||
*
|
||||
* @param string[] $values
|
||||
*/
|
||||
public function datalist(string $id, array $values): static
|
||||
{
|
||||
$options = '';
|
||||
foreach ($values as $v) {
|
||||
$options .= '<option value="' . esc_attr($v) . '">';
|
||||
}
|
||||
$this->before[] = '<datalist id="' . esc_attr($id) . '">' . $options . '</datalist>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A spanning note row inside the table — useful for contextual hints
|
||||
* between fields, rendered as a full-width <td colspan="2">.
|
||||
*/
|
||||
public function note(string $text): static
|
||||
{
|
||||
$this->rows[] = '<tr><td colspan="2"><p class="description">' . esc_html($text) . '</p></td></tr>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress admin notice — emitted before the table.
|
||||
*
|
||||
* @param string $type One of: success | error | warning | info
|
||||
*/
|
||||
public function notice(string $type, string $message, bool $dismissible = true): static
|
||||
{
|
||||
$classes = 'notice notice-' . esc_attr($type) . ($dismissible ? ' is-dismissible' : '');
|
||||
$this->notices[] = '<div class="' . $classes . '"><p>' . esc_html($message) . '</p></div>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Submit button — emitted after the table as <p class="submit">. */
|
||||
public function submit(string $label, string $css_class = 'button-primary', string $name = ''): static
|
||||
{
|
||||
$name_attr = $name !== '' ? 'name="' . esc_attr($name) . '"' : '';
|
||||
$this->after[] = '<p class="submit"><button type="submit" class="button ' . esc_attr($css_class) . '" ' . $name_attr . '>' . esc_html($label) . '</button></p>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
$html = implode('', $this->notices);
|
||||
$html .= implode('', $this->before);
|
||||
|
||||
if (!empty($this->rows)) {
|
||||
$html .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>';
|
||||
}
|
||||
|
||||
$html .= implode('', $this->after);
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function output(): void
|
||||
{
|
||||
echo $this->render();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function row(string $id, string $label, string $control_html, string $desc): static
|
||||
{
|
||||
$d = $desc !== '' ? '<p class="description">' . esc_html($desc) . '</p>' : '';
|
||||
$this->rows[] = '<tr>'
|
||||
. '<th><label for="' . esc_attr($id) . '">' . esc_html($label) . '</label></th>'
|
||||
. '<td>' . $control_html . $d . '</td>'
|
||||
. '</tr>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, string> $options
|
||||
*/
|
||||
private function build_options(array $options, string $selected): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($options as $value => $text) {
|
||||
$is_selected = (string) $value === $selected ? 'selected' : '';
|
||||
$html .= '<option value="' . esc_attr((string) $value) . '" ' . $is_selected . '>' . esc_html($text) . '</option>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Forms;
|
||||
|
||||
/**
|
||||
* Inline field collector used inside RsvFormBuilder::group().
|
||||
*
|
||||
* Each method appends a labelled input fragment. On render() all fragments
|
||||
* are placed in a flex row, emitted as the <td> content of a single table row.
|
||||
*/
|
||||
class RsvFormGroup
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $items = [];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function create(): static
|
||||
{
|
||||
return new static();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function text(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<input class="regular-text" type="text" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $req . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function email(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = '',
|
||||
?string $list_id = null
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$list = $list_id !== null ? 'list="' . esc_attr($list_id) . '"' : '';
|
||||
$ctrl = '<input class="regular-text" type="email" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $list . ' ' . $req . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function password(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $placeholder = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ph = $placeholder !== '' ? 'placeholder="' . esc_attr($placeholder) . '"' : '';
|
||||
$ctrl = '<input class="regular-text" type="password" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" ' . $ph . ' ' . $req . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function number(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string|int $value = '',
|
||||
?int $min = null,
|
||||
?int $max = null
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$min_attr = $min !== null ? 'min="' . $min . '"' : '';
|
||||
$max_attr = $max !== null ? 'max="' . $max . '"' : '';
|
||||
$ctrl = '<input class="small-text" type="number" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr((string) $value) . '" ' . $min_attr . ' ' . $max_attr . ' ' . $req . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function date(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<input class="regular-text" type="date" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $req . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function time(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<input type="time" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" value="' . esc_attr($value) . '" ' . $req . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function checkbox(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $checked = false
|
||||
): static {
|
||||
$c = $checked ? 'checked' : '';
|
||||
$ctrl = '<input type="checkbox" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" ' . $c . '>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, string> $options Associative: value => display text.
|
||||
*/
|
||||
public function select(
|
||||
string $id,
|
||||
string $label,
|
||||
array $options,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $selected = ''
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$opts = $this->build_options($options, $selected);
|
||||
$ctrl = '<select id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" ' . $req . '>' . $opts . '</select>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
public function textarea(
|
||||
string $id,
|
||||
string $label,
|
||||
string $desc = '',
|
||||
bool $required = false,
|
||||
string $value = '',
|
||||
int $rows = 5
|
||||
): static {
|
||||
$req = $required ? 'required' : '';
|
||||
$ctrl = '<textarea class="large-text" id="' . esc_attr($id) . '" name="' . esc_attr($id) . '" rows="' . $rows . '" ' . $req . '>'
|
||||
. esc_textarea($value)
|
||||
. '</textarea>';
|
||||
return $this->item($id, $label, $ctrl, $desc);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div style="display:flex; gap:1.5em; align-items:flex-end; flex-wrap:wrap;">'
|
||||
. implode('', $this->items)
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function item(string $id, string $label, string $control_html, string $desc): static
|
||||
{
|
||||
$d = $desc !== '' ? '<p class="description">' . esc_html($desc) . '</p>' : '';
|
||||
$this->items[] = '<span style="display:flex; flex-direction:column; gap:.25em;">'
|
||||
. '<label for="' . esc_attr($id) . '">' . esc_html($label) . '</label>'
|
||||
. $control_html
|
||||
. $d
|
||||
. '</span>';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, string> $options
|
||||
*/
|
||||
private function build_options(array $options, string $selected): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($options as $value => $text) {
|
||||
$is_selected = (string) $value === $selected ? 'selected' : '';
|
||||
$html .= '<option value="' . esc_attr((string) $value) . '" ' . $is_selected . '>' . esc_html($text) . '</option>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Logger;
|
||||
|
||||
class Logger {
|
||||
private const FILE = 'reservair.log';
|
||||
|
||||
public static function info(string $message): void {
|
||||
self::write($message, 'info');
|
||||
}
|
||||
|
||||
public static function warning(string $message): void {
|
||||
self::write($message, 'warning');
|
||||
}
|
||||
|
||||
/** Accepts a Throwable so it can replace error_log($e) call sites directly. */
|
||||
public static function error(string|\Throwable $message): void {
|
||||
self::write($message instanceof \Throwable ? (string) $message : $message, 'error');
|
||||
}
|
||||
|
||||
public static function get_path(): string {
|
||||
return self::dir() . '/' . self::FILE;
|
||||
}
|
||||
|
||||
/** @return array<array{time: string, level: string, message: string}> Most-recent entry first. */
|
||||
public static function get_entries(): array {
|
||||
$path = self::get_path();
|
||||
if (!file_exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$entry = json_decode($line, true);
|
||||
if ($entry !== null) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return array_reverse($entries);
|
||||
}
|
||||
|
||||
public static function clear(): void {
|
||||
$path = self::get_path();
|
||||
if (file_exists($path)) {
|
||||
file_put_contents($path, '', LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
private static function write(string $message, string $level): void {
|
||||
self::ensure_dir();
|
||||
$line = json_encode([
|
||||
'time' => (new \DateTime('now', wp_timezone()))->format('Y-m-d H:i:s'),
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
]);
|
||||
file_put_contents(self::get_path(), $line . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function dir(): string {
|
||||
static $dir = null;
|
||||
if ($dir === null) {
|
||||
$dir = wp_upload_dir()['basedir'] . '/reservair';
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private static function ensure_dir(): void {
|
||||
$dir = self::dir();
|
||||
if (!is_dir($dir)) {
|
||||
wp_mkdir_p($dir);
|
||||
// Block direct browser access to the log file.
|
||||
file_put_contents($dir . '/.htaccess', "Deny from all\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Logger Module
|
||||
|
||||
A file-based logger that writes structured entries to a JSONL file in the WordPress uploads directory. Designed as a drop-in replacement for `error_log()` that produces entries you can read, table-display, and download from the admin UI.
|
||||
|
||||
**Namespace:** `Reservair\Logger`
|
||||
**Log file:** `wp-content/uploads/reservair/reservair.log`
|
||||
|
||||
## Usage
|
||||
|
||||
```php
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
Logger::info('Timetable reservation created.');
|
||||
Logger::warning('Maintainer email not set for timetable #' . $id);
|
||||
Logger::error('Insert failed: ' . $message);
|
||||
|
||||
// Accepts Throwable directly — replaces error_log($e) call sites directly
|
||||
Logger::error($exception);
|
||||
```
|
||||
|
||||
## Admin UI integration
|
||||
|
||||
```php
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
// Table — get_entries() returns newest-first; each entry has time/level/message keys
|
||||
foreach (Logger::get_entries() as $entry) {
|
||||
// $entry['time'] e.g. "2026-05-30 14:22:01"
|
||||
// $entry['level'] "info" | "warning" | "error"
|
||||
// $entry['message'] the log text
|
||||
}
|
||||
|
||||
// Download button — serve this path as a file download
|
||||
$path = Logger::get_path();
|
||||
|
||||
// Clear the log
|
||||
Logger::clear();
|
||||
```
|
||||
|
||||
## Log file format
|
||||
|
||||
Each line is a JSON object (JSONL). Safe to tail, grep, or import into any log viewer.
|
||||
|
||||
```json
|
||||
{"time":"2026-05-30 14:22:01","level":"info","message":"Reservation #12 created."}
|
||||
{"time":"2026-05-30 14:22:03","level":"error","message":"Insert failed: Duplicate entry"}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Written to `wp-content/uploads/reservair/` rather than the plugin directory so the file survives plugin updates.
|
||||
- The directory is created on first write with an `.htaccess` that blocks direct browser access (`Deny from all`).
|
||||
- `LOCK_EX` is used on every write to prevent interleaved entries under concurrent requests.
|
||||
- `wp_upload_dir()` result is cached statically to avoid repeated database lookups per request.
|
||||
Reference in New Issue
Block a user