This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
+132
View File
@@ -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);
}
}
+94
View File
@@ -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.
+302
View File
@@ -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;
}
}
+187
View File
@@ -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;
}
}
+76
View File
@@ -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");
}
}
}
+54
View File
@@ -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.