commit 0d829845c40e554a8d7a7ca12c2906f9d03aa612 Author: Martin Slachta Date: Thu Jun 11 19:03:29 2026 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b07ac6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +build +vendor/ +dist + +# Editors +.claude/ +.idea/ +.pytest_cache/ diff --git a/.phpactor.json b/.phpactor.json new file mode 100644 index 0000000..c016e80 --- /dev/null +++ b/.phpactor.json @@ -0,0 +1,4 @@ +{ + "$schema": "/phpactor.schema.json", + "language_server_psalm.enabled": true +} \ No newline at end of file diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 0000000..05e5098 --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,6 @@ +{ + "core": "WordPress/WordPress", + "plugins": [ + "." + ] +} \ No newline at end of file diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..dfc9a62 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,12 @@ +{ + "languages": { + "Python": { + "language_servers": ["pyright"], + "settings": { + "python": { + "pythonPath": "tests/rsv-tests/.venv/bin/python" + } + } + } + } +} diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..598f8a5 --- /dev/null +++ b/.zed/tasks.json @@ -0,0 +1,11 @@ +[ + { + "label": "Run python file", + "command": "python3", + "args": ["$ZED_FILE"], + "use_new_terminal": false, + "env": { + "PYTHONPATH": "." + } + } +] diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..943ed73 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,222 @@ +# Architecture + +This file contains description of the software architecture. Before starting any development, consult this file. + +The plugin is made out of a Gutenberg widget that communicates with the backend using endpoints handled by PHP. The endpoints are defined in the *controllers*, that handle everything between the request and creating a application specific models to pass down to the application. It hides all the details about network communication. + +## Forms + +The main component of the plugin are the *forms*. The administrator defines information that the user submits to them. There are two objects, the *definition* and *submission*. The administrator makes the definition: list of fields & elements the form is made out of. The user fills in the information and submits the form. + +### Submitting forms + +Use POST endpoint `forms/{form_id}` to submit form. The `RsvFormProcessor` will validate the form and call handler for each element in the definition. + +## JavaScript + +The JavaScript is mostly used for frontend components and slightly for the administration editor of the Wordpress widget. Each JS file and symbol in global scope must have the prefix `Rsv` or `rsv`. Each component should be encapsulated within it's own `const` object in separate file. The root directory is `assets/js/components/`. + +The other JavaScript files are split between user-space and admin-space. Example of admin-space component is Wordpress-looking datagrid. Example of user-space component is calendar. + +The JavaScript code should always use a resource object for communicating with the backend REST API. See **Data Layer** below. + +### Error handling + + +## Data Layer + +All REST API communication goes through the data layer in `assets/js/datasource/`. + +### RsvDataSource + +`RsvDataSource` is a factory object with a single method: + +```js +RsvDataSource.create_rsv_resource(base_url, { nonce }) +``` + +It returns a resource object that wraps `fetch` and provides a consistent CRUD interface: + +| Method | Signature | HTTP | +|---|---|---| +| `get_page` | `(skip = 0, limit = 20, params = {})` | `GET base_url?skip=…&limit=…` | +| `get` | `(id)` | `GET base_url/{id}` | +| `post` | `(data)` | `POST base_url` | +| `put` | `(id, data)` | `PUT base_url/{id}` | +| `delete` | `(id)` | `DELETE base_url/{id}` | + +Every method returns a `Promise`. Non-2xx responses reject with an `Error`. A `204 No Content` response resolves to `null`. + +When a `nonce` is supplied, it is sent as the `X-WP-Nonce` header, which satisfies WordPress REST API authentication. + +### Resource files + +Each REST endpoint has its own factory function in `assets/js/datasource/`. The factory calls `RsvDataSource.create_rsv_resource` and fills in the endpoint path using the `ReservairServiceAPI.restUrl` global (injected by PHP via `wp_localize_script`). + +| File | Factory | Endpoint | +|---|---|---| +| `RsvReservationResource.js` | `RsvReservationResource()` | `/reservation` | +| `RsvFormDefinitionResource.js` | `RsvFormDefinitionResource()` | `/form-definition` | +| `RsvTimetableResource.js` | `RsvTimetableResource()` | `/timetable` | +| `RsvTimetableCapacityResource.js` | `RsvTimetableCapacityResource(id)` | `/timetable/{id}/capacity` | + +To add a new endpoint, create a new file following the same pattern and register it in `includes/RsvAssetsDefinition.php`. + +## Admin REST Forms + +Admin pages that write to the REST API follow a standard pattern to keep the code consistent and easy to extend. + +### HTML + +Use a `
` element with three data attributes: + +```html + + + + + +
+``` + +- `action` — the full REST endpoint URL (use `get_rest_url` + `esc_url`). +- `data-method` — HTTP verb (`POST`, `PATCH`, `PUT`, `DELETE`). Standard HTML forms only support GET/POST; this attribute carries the real verb for the JS layer. +- `data-success-msg` — human-readable message passed to `show_notice` on success. + +Fields that should not appear as literal HTML inputs (e.g. a ``) runs independently of the submit listener and writes its results into named `` elements so `FormData` picks them up transparently. + +--- + +## Forms + +The plugin allows administrators to define & display forms on pages as a widget. The definitions is an ordered list of element definitions in a JSON format. The required attributes for each element are `name`, `label` and `is_required`. The optional are `description`. + +To define new element, create a PHP class in `includes/Services/Forms/` like `RsvFormHelloElementHandler` that derives from `RsvFormElementHandler` and implement all the abstract methods. The `RsvFormHandler` validates and commits the forms. + +The form values are saved as JSON with keys for the input elements. + + +## PHP + +### Controllers + +Builds the REST API. Should not assume anything about the application and mainly just pass built domain object to the application layer. Each controller manages a single resource and defines operations that the user can do with the object. + +--- + +## Permissions & Capabilities + +All administrative functionality — both the REST API and the admin pages — is gated by a **single custom capability**, and every REST route declares its intended audience through a small policy class. There are two source-of-truth symbols; nothing else should appear in authorization code: + +| Symbol | File | Purpose | +|---|---|---| +| `RsvCapabilities::MANAGE` (`'manage_reservations'`) | `includes/RsvCapabilities.php` | The capability that authorises managing reservation data. | +| `RsvRestPolicy::admin()` / `RsvRestPolicy::open()` | `includes/Controllers/RsvRestPolicy.php` | The `permission_callback` tiers every route uses. | + +> **Rule:** never write `'manage_options'` or `'__return_true'` in this plugin. Routes go through `RsvRestPolicy`; menus and page-level checks use `RsvCapabilities::MANAGE`. This keeps the whole authorization surface greppable and consistent. + +### The capability + +`manage_reservations` is a **custom** capability (not WordPress's built-in `manage_options`) so that reservation management can later be granted to a non-admin role without also handing over the whole site. By default it is granted only to the `administrator` role. + +### Lifecycle + +WordPress only runs the activation hook on *activate*, never on a plugin *update* — so granting the cap solely on activation would silently lock admins out after an update. `RsvCapabilities` handles all three moments: + +| Moment | Call | Effect | +|---|---|---| +| Activation | `RsvInstaller::install()` → `RsvCapabilities::ensure()` | Grants the cap to the default roles. | +| Every request | `rsv_bootstrap()` → `RsvCapabilities::ensure()` | Self-heals after an update. No-op once the stored version matches. | +| Uninstall | `uninstall.php` → `RsvCapabilities::revoke()` | Removes the cap from every role and clears the marker. | + +`ensure()` is idempotent and guarded by the `rsv_caps_version` option compared against `RsvCapabilities::VERSION`. **Bump `VERSION` whenever the capability set changes** so existing installs re-grant on their next request. Because `ensure()` runs on every load, admins never need to manually reactivate the plugin. + +### Authorization tiers + +Each route's `permission_callback` is one of: + +| Tier | Callback | Means | +|---|---|---| +| **admin** | `[RsvRestPolicy::class, 'admin']` | Caller must have `manage_reservations`. Returns a `WP_Error` (401 logged-out / 403 under-privileged) otherwise. | +| **open** | `[RsvRestPolicy::class, 'open']` | Either genuinely public (form submission, availability lookups) **or** a *capability URL* whose secret is validated inside the handler. | + +A **capability URL** is a public endpoint authorised by an unguessable secret in the request rather than by session auth — e.g. the maintainer accept/refuse links (`/timetable-reservation/accept|refuse/{code}`), the Google webhook (secret channel id in headers), and the OAuth callback. Each is marked `open()` with a comment naming where its secret is checked. + +> **Rule:** any `open()` route that is not fully public **must** authorise its caller from the request inside the handler. `open()` performs no check itself. + +### Using a capability + +**In a REST route** (`register_rest_route`): + +```php +register_rest_route($this->namespace, '/' . $this->resource_name, [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], // or 'open' +]); +``` + +**In the admin menu** (`add_menu_page` / `add_submenu_page`) — pass the constant as the capability argument: + +```php +add_menu_page('Reservations', 'Reservations', RsvCapabilities::MANAGE, 'reservations-settings', 'rsv_reservations_page'); +``` + +**In a page-level / inline check:** + +```php +if (!current_user_can(RsvCapabilities::MANAGE)) { + return; +} +``` + +### Adding a new capability + +The current design hardcodes the single `MANAGE` capability for the `administrator` role. To introduce a second (e.g. a read-only `view_reservations` for an `editor`): + +1. **Declare it** as a constant on `RsvCapabilities` (`public const VIEW = 'view_reservations';`). +2. **Grant it** in `RsvCapabilities::ensure()` and **remove it** in `revoke()`. Once there is more than one capability, replace the single-cap logic with a `capability => [roles]` map and iterate it in both methods, so the two stay in sync. +3. **Bump `RsvCapabilities::VERSION`** so `ensure()` re-applies the new grant on existing installs at their next request. +4. **Expose a tier** for it: add a method to `RsvRestPolicy` (e.g. `viewer()` checking `current_user_can(RsvCapabilities::VIEW)`), following the `admin()` pattern (return `true` or a `WP_Error` with `rest_authorization_required_code()`). +5. **Reference it** from the relevant routes, menu entries, and page checks — never the literal string. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..47f7409 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +SHELL := /bin/bash + +.PHONY: help build clean + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +build: ## Build the installable plugin ZIP into dist/ + @./bin/build-zip.sh + +clean: ## Remove build artifacts (dist/) + @rm -rf dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..b613f49 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Reservair + +A WordPress plugin for managing reservations and bookings. Visitors submit reservation requests through a Gutenberg block; administrators manage everything through a dedicated admin panel. + +## Requirements + +- WordPress 6.7+ +- PHP 7.4+ +- Composer +- Node.js / npm + +## Admin Menu + +| Page | Slug | Description | +|---|---|---| +| Reservations (root) | `reservations-settings` | Services — the reservable resources | +| Timetables | `timetable-settings` | Time-block schedules with capacity and repeating windows | +| Forms | `forms-settings` | Custom reservation form definitions | +| Reservations | `reservations-list` | All submissions with accept / reject actions | +| Google Calendar | `rsv-google-calendar` | OAuth2 connect and webhook configuration | + +## REST API + +All routes are registered under the `reservations/v1` namespace. + +| Resource | Controller | +|---|---| +| `/service` | `RsvServicesController` | +| `/service-type` | `RsvServiceTypeController` | +| `/reservation` | `RsvReservationController` | +| `/timetable` | `RsvTimetableDefinitionController` | +| `/timetable/availability` | `RsvTimetableAvailabilityController` | +| `/timetable/capacity` | `RsvTimetableCapacityController` | +| `/timetable/reservation` | `RsvTimetableReservationController` | +| `/form` | `RsvFormController` | +| `/form-definition` | `RsvFormDefinitionController` | +| `/google-callback` | OAuth2 redirect handler | +| `/google-calendar-hook` | Google Calendar push notification webhook | + +## Linting + +Static analysis is handled by [Psalm](https://psalm.dev/) and covers both `src/` (frontend PHP) and `includes/` (plugin PHP). + +```bash +vendor/bin/psalm +``` + +Pre-existing issues are suppressed in `psalm-baseline.xml`. New code must introduce no new errors — any issue not in the baseline will cause a non-zero exit. + +**Updating the baseline** — after intentionally fixing pre-existing issues, shrink the baseline so they don't regress: + +```bash +vendor/bin/psalm --update-baseline +``` + +**Psalm is part of the `lint` Composer script**, alongside `phpcs` and `phpstan` (those require separate installation): + +```bash +composer run lint +``` + +## Running Tests + +See [`tests/README.md`](tests/README.md) for setup and usage. + +## Build + +```bash +npm install +npm run build +``` + +```bash +composer install +``` diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..718b74d --- /dev/null +++ b/admin.php @@ -0,0 +1,5 @@ +* { + flex-grow: 1; +} + +.rsv-calendar td { + -webkit-user-select:none;user-select:none; + z-index: -1; +} + +.rsv-calendar td label { + display: block; + padding: 0.25em; + border-radius: var(--s-4); + transition: background-color 0.3s ease; +} + +/*.calendar td:hover label { + background-color: var(--color-gray-100); +}*/ + +.rsv-calendar td>div { + background-color: white; + padding: 0.5rem; + transition: background-color 0.3s ease; +} + +.rsv-calendar td.today { + color: var(--selected); +} + +.rsv-calendar td button { + transition: background-color 0.3s ease; + padding: 0.5rem; +} +/*.calendar td button:hover { + cursor: pointer; + background-color: var(--hover-bg); +}*/ + +.rsv-calendar td.selected>div { + position: relative; + color: white; + background-color: var(--color-blue-500); +} + + +.rsv-calendar td.dimm { + /*background-color: var(--dimm-bg);*/ +} + +.rsv-calendar tr:last-child>td { + border-bottom: none; +} + +.rsv-calendar tr>td:last-child { + border-right: none; +} + +.rsv-calendar td label { + width: 100%; + height: 100%; + padding: 0; + line-height: 2.4rem; + margin: 0; +} + +.rsv-calendar input[type="radio"]:checked+label { + background-color: var(--color-blue-500); + color: white; +} + +.rsv-cal-cell.dimm { + color: gray; +} + +/* CALENDAR END */ diff --git a/assets/css/components/RsvFormStyles.css b/assets/css/components/RsvFormStyles.css new file mode 100644 index 0000000..75c0685 --- /dev/null +++ b/assets/css/components/RsvFormStyles.css @@ -0,0 +1,217 @@ +/* Primary CTA (submit / confirm) */ +.rsv-form-btn-primary { + background: #2563eb; + color: #fff; + + border-radius: 1.375rem; + font-size: 1rem; + padding: 0 calc(1.25rem + 4px); + line-height: 140%; + height: 3.5rem; + + + border: none; + font-size: 15px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + width: 100%; + transition: background .12s; + letter-spacing: -.01em; +} + +.rsv-form-btn-primary:hover { + background: #1d4ed8; +} + +.rsv-form-btn-primary:disabled { + background: #e0e0e0; + color: #aaa; + cursor: not-allowed; +} + +/* FORM */ +.reservair-form { + margin-left: auto; + margin-right: auto; +} + + +.rsv-form-input-short { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +/*.reservair-form button { + padding: var(--s-3) !important; + font-weight: 400 !important; +}*/ + +/*.reservair-form button,*/ +.rsv-form-input { + border: 1px solid var(--color-gray-300); + outline: none; + padding: var(--s-2); + border-radius: var(--s-2); + width: 100%; + box-sizing: border-box; + background-color: var(--color-gray-50); + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +/*.reservair-form button,*/ +.rsv-form-input[type="submit"] { + background-color: var(--color-blue-500); + padding: 0.5rem; + color: white; + border: none; + font-size: 0.875rem; + font-weight: 600; + transition: background-color 0.2s ease; +} + +.rsv-form-input[type="submit"]:hover { + background-color: var(--color-blue-400); +} + +.reservair-form button.rsv-loading { + color: transparent; + pointer-events: none; + position: relative; +} + +.reservair-form button.rsv-loading::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + width: 1em; + height: 1em; + border: 2px solid white; + border-top-color: transparent; + border-radius: 50%; + animation: rsv-spin 0.6s linear infinite; +} + +@keyframes rsv-spin { + to { transform: rotate(360deg); } +} + +.rsv-form-input:focus { + box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-blue-500)25%,transparent); + border-color: var(--color-blue-500); +} + +.rsv-form-input input:user-invalid { + border-color: var(--color-red-500); + box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent); +} + +.rsv-form-section { + margin-bottom: var(--s-5); +} + +.rsv-form-input-group>* { + margin-bottom: var(--s-1); +} + +.rsv-form-input-group { + margin-bottom: var(--s-4); +} + +.rsv-form-label, +.rsv-form-small { + padding-left: 5pt; + font-size: 0.875rem; + font-weight: 500; + display: block; +} + +.rsv-form-small { + color: gray; +} + +/*.confirmation small { + color: var(--color-gray-500); +}*/ + +.rsv-error-summary { + background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); + border: 1px solid var(--color-red-400); + border-radius: var(--s-2); + padding: var(--s-2) var(--s-3); + margin-bottom: var(--s-3); + font-size: 0.875rem; + color: var(--color-red-800); +} + +.rsv-error-summary ul { + margin: 0; + padding-left: 1.25rem; +} + +.rsv-field-error { + display: block; + color: var(--color-red-600); + font-size: 0.8rem; + margin-top: var(--s-1); + padding-left: 5pt; +} + +.rsv-invalid { + border-color: var(--color-red-500) !important; + box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important; +} + +.rsv-success-message { + text-align: center; + padding: var(--s-5); + color: var(--color-green-700); +} + +.rsv-success-message p { + font-size: 1.125rem; + font-weight: 500; +} + + +.mesg { + width: 100%; + text-align: center; + line-height: 1rem; + margin-top: var(--s-5); + margin-bottom: var(--s-5); + padding: var(--s-4) 0; +} + +.success-mesg-icon { +} + +.mesg-icon svg { + width: 32px; + height: 32px; + padding: var(--s-2); + border-radius: 50%; + color: #00000094; +} + +.error-mesg svg { + background-color: var(--color-red-200); +} + +.success-mesg svg { + background-color: rgba(0, 201, 80, 0.36); +} +/* FORM END */ + +.rsv-timetable-selector { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +@media (max-width: 768px) { + .rsv-timetable-selector { + grid-template-columns: repeat(1, 1fr); + } +} diff --git a/assets/css/components/RsvFormSummaryStyles.css b/assets/css/components/RsvFormSummaryStyles.css new file mode 100644 index 0000000..16650b1 --- /dev/null +++ b/assets/css/components/RsvFormSummaryStyles.css @@ -0,0 +1,133 @@ +/* ----- Summary (selected slots + price) ----- */ +rsv-reservation-summary { + display: block; + margin-bottom: var(--s-4); +} + +rsv-reservation-summary { + padding: 14px 20px; + background: #f8faff; + border: 1px solid #e8f0fe; + border-radius: 1.375rem; + + box-sizing: border-box; + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.rsv-summary-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.rsv-summary-title { + font-size: 11px; + font-weight: 700; + color: #2563eb; + text-transform: uppercase; + letter-spacing: .06em; +} + +.rsv-summary-clear { + font-size: 11px; + font-weight: 600; + color: #aaa; + background: none; + border: none; + font-family: inherit; + cursor: pointer; + padding: 0; +} + +.rsv-summary-clear:hover { color: #e53e3e; } + +.rsv-summary-list { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; + padding: 0; + margin: 0 0 10px; +} + +.rsv-summary-item { + display: flex; + align-items: center; + justify-content: space-between; + background: #fff; + border: 1.5px solid #e8f0fe; + border-radius: 10px; + padding: 8px 12px; +} + +.rsv-summary-item-info { + display: flex; + flex-direction: column; + gap: 1px; +} + +.rsv-summary-item-date { + font-size: 11px; + color: #888; +} + +.rsv-summary-item-time { + font-size: 13px; + font-weight: 600; + color: #0f0f0f; +} + +.rsv-summary-item-price { + font-size: 1rem; +} + +.rsv-summary-item-remove { + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: #f0f0f0; + color: #888; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all .12s; + flex-shrink: 0; +} + +.rsv-summary-item-remove:hover { + background: #fee2e2; + color: #e53e3e; +} + +.rsv-summary-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 10px; + border-top: 1px solid #e8f0fe; +} + +.rsv-summary-count { + font-size: 12px; + color: #888; +} + +.rsv-summary-price { + font-size: 16px; + font-weight: 700; + color: #0f0f0f; + letter-spacing: -.02em; +} + +.rsv-summary-price span { + font-size: 12px; + font-weight: 500; + color: #888; + margin-left: 2px; +} diff --git a/assets/css/components/RsvTimeSlotsStyles.css b/assets/css/components/RsvTimeSlotsStyles.css new file mode 100644 index 0000000..e611519 --- /dev/null +++ b/assets/css/components/RsvTimeSlotsStyles.css @@ -0,0 +1,153 @@ +/* TIME SLOTS */ +.rsv-time-slots { + padding: 1rem; + border-left: var(--border); +} + +.rsv-slots-notice { + display: block; + text-align: center; + grid-column-start: 0; + grid-column-end: 1; + font-weight: 400; + font-size: 0.875rem; +} + +.rsv-slots-slot-time { + font-size: 0.875rem; + font-weight: 600; + padding: 0.5rem; + -webkit-user-select:none;user-select:none; +} + +.rsv-slots-slot-time>.content { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + align-content: center; + + width: 100%; + padding: 0.5rem 1.5rem; +} + +.rsv-slots-slot-time .capacity { + font-size: 1rem; + color: var(--color-gray-400); +} + +.rsv-slots-slot-time .capacity>* { + display: block; + text-align: center; + width: 100%; +} + +label.rsv-slots-slot-time>input:checked + .content>.capacity { + color: rgba(255, 255, 255, 0.7); +} + +.reservation-block.blocked>.rsv-slots-slot-time>.content { + opacity: 0.8; + color: var(--color-gray-500); + text-decoration: line-through; + +} + +.rsv-slots-label { + font-size: 11px; + font-weight: 600; + color: #999; + text-transform: uppercase; + letter-spacing: .06em; + margin-bottom: 12px; + padding-top: 0.5rem; +} + +.rsv-slots-list { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; + padding: 1rem; + margin: 0; +} + +/* Base slot */ +.rsv-slots-slot { + margin-top: 0.375rem; + border: 1.5px solid #e8e8e8; + border-radius: 10px; + padding: 9px 12px; + cursor: pointer; + transition: border-color .12s, background .12s, color .12s; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + font-weight: 600; +} + +.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-selected) { + border-color: #2563eb; + background: #f5f8ff; +} + +/* Available — plenty of spots */ +.rsv-slots-slot-available { + background: #f0fdf4; + border-color: #86efac; + color: #166534; +} + +/* Few spots left */ +.rsv-slots-slot-few { + background: #fff8f0; + border-color: #f59e0b; + color: #92400e; +} + +/* Fully booked */ +.rsv-slots-slot-full { + background: #fafafa; + border-color: #e8e8e8; + color: #bbb; + text-decoration: line-through; + cursor: not-allowed; +} + +/* Selected */ +.rsv-slots-slot-selected { + background: #2563eb; + border-color: #2563eb; + color: #fff; +} + +/* Availability badge (small pill inside slot) */ +.rsv-slots-slot-badge { + font-size: 10px; + font-weight: 600; + border-radius: 6px; + padding: 2px 7px; +} + +.rsv-slots-slot-badge-available { + background: #dcfce7; + color: #166534; +} + +.rsv-slots-slot-badge-few { + background: #fef3c7; + color: #92400e; +} + +.rsv-slots-slot-badge-full { + background: #f3f4f6; + color: #9ca3af; +} + +.rsv-slots-slot-badge-selected { + background: rgba(255, 255, 255, .2); + color: #fff; +} + +/* TIMELINE END */ diff --git a/assets/js/RsvApi.js b/assets/js/RsvApi.js new file mode 100644 index 0000000..eab7d7f --- /dev/null +++ b/assets/js/RsvApi.js @@ -0,0 +1,7 @@ +/* + * Utilities for calling the API + */ + +function get_rest_url(resource) { + return ReservairServiceAPI.restUrl + '/' + resource; +} diff --git a/assets/js/RsvDomHelper.js b/assets/js/RsvDomHelper.js new file mode 100644 index 0000000..e69de29 diff --git a/assets/js/datasource/README.md b/assets/js/datasource/README.md new file mode 100644 index 0000000..09a665f --- /dev/null +++ b/assets/js/datasource/README.md @@ -0,0 +1,5 @@ +# JS Data Sources + +The JavaScript *RSV* Client layer. The frontend uses `RsvDataSource` to work with the Reservair's REST API. + +The `RsvDataSource` is an object for a specific resource & contains methods for working with it. diff --git a/assets/js/datasource/RsvDataSource.js b/assets/js/datasource/RsvDataSource.js new file mode 100644 index 0000000..72c6da4 --- /dev/null +++ b/assets/js/datasource/RsvDataSource.js @@ -0,0 +1,44 @@ +const RsvDataSource = { + create_rsv_resource(base_url, { nonce } = {}) { + function request(url, method, body) { + const headers = { 'Content-Type': 'application/json' }; + if (nonce) headers['X-WP-Nonce'] = nonce; + else headers['X-WP-Nonce'] = ReservairServiceAPI.nonce; + + return fetch(url, { + method, + credentials: 'same-origin', + headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }).then(r => { + if (!r.ok) throw new Error(`${method} ${url} failed: ${r.status}`); + return r.status === 204 ? null : r.json(); + }); + } + + return { + base_url: base_url, + get_page(skip = 0, limit = 20, params = {}) { + const url = new URL(base_url); + url.searchParams.set('skip', skip); + url.searchParams.set('limit', limit); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + return request(url, 'GET'); + }, + get(id) { + return request(`${base_url}/${id}`, 'GET'); + }, + post(data) { + return request(base_url, 'POST', data); + }, + put(id, data) { + return request(`${base_url}/${id}`, 'PUT', data); + }, + delete(id) { + return request(`${base_url}/${id}`, 'DELETE'); + }, + }; + } +}; diff --git a/assets/js/datasource/RsvFormDefinitionResource.js b/assets/js/datasource/RsvFormDefinitionResource.js new file mode 100644 index 0000000..577fc00 --- /dev/null +++ b/assets/js/datasource/RsvFormDefinitionResource.js @@ -0,0 +1,2 @@ +const RsvFormDefinitionResource = () => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/form-definition'); diff --git a/assets/js/datasource/RsvReservationClient.js b/assets/js/datasource/RsvReservationClient.js new file mode 100644 index 0000000..11de9a0 --- /dev/null +++ b/assets/js/datasource/RsvReservationClient.js @@ -0,0 +1,19 @@ +const RsvReservationClient = { + accept(reservation_id) { + return this._post(reservation_id, 'accept'); + }, + + refuse(reservation_id) { + return this._post(reservation_id, 'refuse'); + }, + + _post(reservation_id, action) { + return fetch(`${ReservairServiceAPI.restUrl}/reservation/${reservation_id}/${action}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce }, + }).then(r => { + if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'Request failed'); }); + }); + }, +}; diff --git a/assets/js/datasource/RsvReservationResource.js b/assets/js/datasource/RsvReservationResource.js new file mode 100644 index 0000000..6421fae --- /dev/null +++ b/assets/js/datasource/RsvReservationResource.js @@ -0,0 +1,2 @@ +const RsvReservationResource = () => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/reservation'); diff --git a/assets/js/datasource/RsvTimetableCapacityResource.js b/assets/js/datasource/RsvTimetableCapacityResource.js new file mode 100644 index 0000000..43a31ce --- /dev/null +++ b/assets/js/datasource/RsvTimetableCapacityResource.js @@ -0,0 +1,2 @@ +const RsvTimetableCapacityResource = (id) => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/capacity`); diff --git a/assets/js/datasource/RsvTimetableReservationResource.js b/assets/js/datasource/RsvTimetableReservationResource.js new file mode 100644 index 0000000..1320572 --- /dev/null +++ b/assets/js/datasource/RsvTimetableReservationResource.js @@ -0,0 +1,2 @@ +const RsvTimetableReservationResource = (id) => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/reservation`); diff --git a/assets/js/datasource/RsvTimetableResource.js b/assets/js/datasource/RsvTimetableResource.js new file mode 100644 index 0000000..c26c9fe --- /dev/null +++ b/assets/js/datasource/RsvTimetableResource.js @@ -0,0 +1,2 @@ +const RsvTimetableResource = () => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/timetable'); diff --git a/assets/js/elements/README.md b/assets/js/elements/README.md new file mode 100644 index 0000000..9dadf91 --- /dev/null +++ b/assets/js/elements/README.md @@ -0,0 +1,3 @@ +# Elements + +Some repeating components of the UI. diff --git a/assets/js/elements/RsvCalendar.js b/assets/js/elements/RsvCalendar.js new file mode 100644 index 0000000..25424c2 --- /dev/null +++ b/assets/js/elements/RsvCalendar.js @@ -0,0 +1,196 @@ +const RsvCalendarPicker = (() => { + + function get_first_day_of_month(date) { + const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay(); + return day === 0 ? 6 : day - 1; // Mon=0 … Sun=6 + } + + function is_same_day(a, b) { + return a.getUTCFullYear() === b.getUTCFullYear() + && a.getUTCMonth() === b.getUTCMonth() + && a.getUTCDate() === b.getUTCDate(); + } + + function is_same_month(a, b) { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth(); + } + + function clear_class(root, cls) { + root.querySelectorAll('.' + cls).forEach(el => el.classList.remove(cls)); + } + + function set_cell(cell, date, outside) { + cell.classList.toggle('dimm', outside); + const iso = date.toISOString(); + cell.setAttribute('datetime', iso); + cell.children[0].id = iso; + cell.children[0].setAttribute('datetime', iso); + cell.children[1].textContent = date.getUTCDate(); + cell.children[1].setAttribute('for', iso); + } + + function render(state, date) { + const year = date.getFullYear(); + const month = date.getMonth(); + const first = get_first_day_of_month(date); + const in_cur = new Date(year, month + 1, 0).getDate(); + const in_prev = new Date(year, month, 0).getDate(); + const today = new Date(); + const rows = state.body.querySelectorAll('tr'); + + clear_class(state.body, 'rsv-cal-cell-current'); + clear_class(state.body, 'rsv-cal-cell-today'); + + let idx = 0; + for (let d = in_prev - first + 1; d <= in_prev; d++, idx++) { + const dt = new Date(Date.UTC(year, month - 1, d)); + const cell = rows[0].children[idx]; + set_cell(cell, dt, true); + if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today'); + } + for (let i = 1; i <= in_cur; i++, idx++) { + const dt = new Date(Date.UTC(year, month, i)); + const cell = rows[Math.floor(idx / 7)].children[idx % 7]; + set_cell(cell, dt, false); + if (is_same_day(dt, date)) cell.querySelector('input').checked = true; + if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today'); + } + for (let i = 1; idx < 42; i++, idx++) { + const dt = new Date(Date.UTC(year, month + 1, i)); + const cell = rows[Math.floor(idx / 7)].children[idx % 7]; + set_cell(cell, dt, true); + if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today'); + } + + state.month_el.textContent = + new Date(year, month).toLocaleString(navigator.language, { month: 'long' }) + ' ' + year; + } + + function day_names() { + // Generate short weekday names starting on Monday using the browser locale. + return Array.from({ length: 7 }, (_, i) => + new Date(2024, 0, 1 + i) // 2024-01-01 is a Monday + .toLocaleDateString(navigator.language, { weekday: 'short' }) + ); + } + + const ARROW_L = ``; + const ARROW_R = ``; + + function nav_btn(icon, handler) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.innerHTML = icon; + btn.classList.add('rsv-cal-btn-nav'); + btn.addEventListener('click', handler); + const wrap = document.createElement('div'); + wrap.appendChild(btn); + return wrap; + } + + function build_header(state) { + const month_el = document.createElement('span'); + month_el.classList.add('rsv-cal-month'); + state.month_el = month_el; + + const controls = document.createElement('div'); + controls.classList.add('rsv-cal-controls'); + controls.append( + nav_btn(ARROW_L, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() - 1, state.date.getDate()))), + month_el, + nav_btn(ARROW_R, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() + 1, state.date.getDate()))) + ); + + const ctrl_td = document.createElement('td'); + ctrl_td.colSpan = 7; + ctrl_td.appendChild(controls); + + const ctrl_row = document.createElement('tr'); + ctrl_row.appendChild(ctrl_td); + + const names_row = document.createElement('tr'); + day_names().forEach(name => { + const th = document.createElement('th'); + th.textContent = name; + names_row.appendChild(th); + }); + + const header = document.createElement('thead'); + header.classList.add('rsv-cal-header'); + header.append(ctrl_row, names_row); + return header; + } + + function build_body(name, on_select) { + const tbody = document.createElement('tbody'); + tbody.classList.add('rsv-cal-grid'); + + for (let y = 0; y < 6; y++) { + const row = document.createElement('tr'); + for (let x = 0; x < 7; x++) { + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = name + '.date'; + radio.hidden = true; + radio.addEventListener('change', on_select); + + const label = document.createElement('label'); + label.setAttribute('unselectable', 'true'); + + const cell = document.createElement('td'); + cell.classList.add('rsv-cal-cell'); + cell.append(radio, label); + row.appendChild(cell); + } + tbody.appendChild(row); + } + return tbody; + } + + return { + create(container, name) { + const state = { + date: null, + month_el: null, + body: null, + container, + + set_date(date) { + if (this.date !== null && is_same_day(date, this.date)) return; + + const month_changed = this.date === null || !is_same_month(date, this.date); + if (month_changed) { + const prev = this.body.querySelector('input[type="radio"]:checked'); + if (prev) prev.checked = false; + render(this, date); + const next = this.body.querySelector(`input[id="${date.toISOString()}"]`); + if (next) next.checked = true; + } + + const first = get_first_day_of_month(date); + const cell_idx = first + date.getDate() - 1; + const rows = this.body.querySelectorAll('tr'); + rows[Math.floor(cell_idx / 7)].children[cell_idx % 7].querySelector('input').checked = true; + + this.date = date; + this.container.value = date; + this.container.dispatchEvent( + new InputEvent('change', { bubbles: true, cancelable: true, composed: true }) + ); + }, + }; + + container.classList.add('rsv-calendar'); + + const table = document.createElement('table'); + table.appendChild(build_header(state)); + table.appendChild(build_body(name, e => state.set_date(new Date(e.target.getAttribute('datetime'))))); + + state.body = table.querySelector('tbody'); + container.appendChild(table); + state.set_date(new Date()); + return state; + }, + }; +})(); diff --git a/assets/js/elements/RsvDatagrid.js b/assets/js/elements/RsvDatagrid.js new file mode 100644 index 0000000..baa77f2 --- /dev/null +++ b/assets/js/elements/RsvDatagrid.js @@ -0,0 +1,422 @@ +/** + * RSV Dynamic datagrid + * Allows fetching with JS instead of page reload. + */ +window.RsvDataGrid = window.RsvDataGrid || { + create_header(self, columns, has_actions) { + let thead = document.createElement('thead'); + + thead.replaceChildren(...Object.entries(columns).map(([key, value]) => { + let th = document.createElement('th'); + + if (value.width) { + th.style.width = value.width + 'px'; + } + + th.classList.add('manage-columns', 'column-' + key); + if (value.is_sortable) { + th.classList.add('sortable', key); + let button = document.createElement('a'); + button.onclick = () => { + self.sort_by(key); + }; + button.innerHTML = + `${value.label} + + + + + Sort ascending. + `; + + th.appendChild(button); + } else { + th.innerText = value.label; + } + return th; + })); + + if (has_actions) { + let th = document.createElement('th'); + th.innerText = 'Actions'; + thead.appendChild(th); + } + + return thead; + }, + + create_footer(self, columns, has_actions) { + let tfoot = document.createElement('tfoot'); + + let trow = document.createElement('tr'); + + trow.replaceChildren(...Object.entries(columns).map(([key, value]) => { + let th = document.createElement('th'); + + if (value.width) { + th.style.width = value.width + 'px'; + } + + th.classList.add('manage-columns', 'column-' + key); + if (value.is_sortable) { + th.classList.add('sortable', key); + let button = document.createElement('a'); + button.onclick = () => { + self.sort_by(key); + }; + button.innerHTML = + `${value.label} + + + + + Sort ascending. + `; + + th.appendChild(button); + } else { + th.innerText = value.label; + } + return th; + })); + + tfoot.appendChild(trow); + + return tfoot; + }, + + create_dg_row(self, data, index = 0) { + let row = document.createElement('tr'); + row.classList.add('iedit', 'author-self', 'level-0', 'type-page', 'status-publish', 'hentry'); + + row.replaceChildren(...Object.entries(self.columns).map(([key, value]) => { + let td = document.createElement('td'); + if (self.mappings[key] != null) { + td = self.mappings[key](self, row, data); + } else { + td.innerText = data[key]; + } + + if(value.actions != null && Object.entries(value.actions).length > 0) { + const visible_actions = Object.entries(value.actions).filter(([, action]) => + action.condition == null || action.condition(data, index) + ); + if (visible_actions.length === 0) return td; + + row.classList.add('has-row-actions') + const action_cell = document.createElement('div'); + action_cell.classList.add('row-actions', 'visible'); + + const action_spans = visible_actions.map(([key, value]) => { + if (value.is_link) { + let span = document.createElement('span'); + let a = document.createElement('a'); + a.innerText = key; + a.href = value.func(data); + span.appendChild(a); + return span; + } else { + let span = document.createElement('span'); + let button = document.createElement('a'); + button.onclick = function () { value.func(self, row, data) }; + button.innerText = key; + span.appendChild(button); + return span; + } + }); + const action_nodes = action_spans.flatMap((span, i) => + i < action_spans.length - 1 ? [span, document.createTextNode(' | ')] : [span] + ); + action_cell.replaceChildren(...action_nodes); + + td.appendChild(action_cell); + } + + return td; + })); + + + + return row; + }, + + + async render_data_grid(self) { + const rows = self.fetch_resource() + .then(x => { self.set_total(x.total); return x; }) + .then(x => x.data.map((x, i) => RsvDataGrid.create_dg_row(self, x, i))) + .then(x => self.body.replaceChildren(...x)) + .catch(error => { + console.error(error); + return []; // empty the rows + }); + }, + + link_action(func, condition = null) { + return { is_link: true, func, condition }; + }, + + func_action(func, condition = null) { + return { is_link: false, func, condition }; + }, + + edit_action(func, condition = null) { + return { is_link: false, func, condition }; + }, + + action_column(label, is_sortable, actions) { + return { + label: label, + is_sortable: is_sortable, + actions: actions, + }; + }, + + column(label, is_sortable = false, width = 0) { + return { + label: label, + is_sortable: is_sortable, + width: width + }; + }, + + create_paging_button(text) { + let button = document.createElement('a'); + button.classList.add('button'); + + let label = document.createElement('span'); + label.innerText = text; + + button.appendChild(label); + + return button; + }, + + create_last_page_btn() { + let btn = this.create_paging_button("»"); + btn.classList.add('last-page'); + return btn; + }, + + create_next_page_btn() { + let btn = this.create_paging_button("›"); + btn.classList.add('next-page'); + return btn; + }, + + create_first_page_btn() { + let btn = this.create_paging_button("«"); + btn.classList.add('first-page'); + return btn; + }, + + create_prev_page_btn() { + let btn = this.create_paging_button("‹"); + btn.classList.add('prev-page'); + return btn; + }, + + create_paging_text() { + let text = document.createElement('span'); + text.classList.add('paging-input'); + + let paging_text = document.createElement('span'); + paging_text.classList.add('tablenav-paging-text'); + + text.appendChild(paging_text); + + const result = { + container: text, + paging_text: paging_text, + }; + + return result; + }, + + create_paging_controls(self) { + let nav = document.createElement('div'); + nav.classList.add('tablenav-pages'); + + let displaying_num = document.createElement('span'); + displaying_num.classList.add('displaying-num'); + + nav.appendChild(displaying_num); + + let pagination_links = document.createElement('span'); + pagination_links.classList.add('pagination-links'); + + let first_page_btn = this.create_first_page_btn(); + first_page_btn.onclick = () => self.goto_first_page(); + let prev_page_btn = this.create_prev_page_btn(); + prev_page_btn.onclick = () => self.move_page(-1); + let next_page_btn = this.create_next_page_btn(); + next_page_btn.onclick = () => self.move_page(1); + let last_page_btn = this.create_last_page_btn(); + last_page_btn.onclick = () => self.goto_last_page(); + + pagination_links.appendChild(first_page_btn); + pagination_links.appendChild(prev_page_btn); + + let paging_text = this.create_paging_text(); + + pagination_links.appendChild(paging_text.container); + + pagination_links.appendChild(next_page_btn); + pagination_links.appendChild(last_page_btn); + + // pagination_links.innerHTML = ` + // + // + // + // Current Page + // + // 1 of 2 + // + // + // Next page + // + // + // + // Last page + // + // + // `; + + nav.appendChild(pagination_links); + + const paging_controls = { + container: nav, + paging_text: paging_text.paging_text, + display_num: displaying_num, + }; + + return paging_controls; + }, + + create_data_grid(container, resource, columns, actions) { + let tbody = document.createElement('tbody'); + + let state = { + columns: columns, + resource: resource, + actions: actions, + body: tbody, + mappings: {}, + params: {}, + total: 0, + page: 0, + page_size: 20, + container: container, + order_by: null, + order: 0, + fetch_resource() { + const params = { ...this.params }; + if (this.order_by) { + params.orderby = this.order_by; + params.order = this.order === 0 ? 'desc' : 'asc'; + } + return this.resource.get_page(this.page * this.page_size, this.page_size, params); + }, + refresh() { + RsvDataGrid.render_data_grid(this) + }, + refresh_row(row, data) { + let row2 = RsvDataGrid.create_dg_row(this, data); + row.replaceWith(row2); + }, + map_column(key, func) { + this.mappings[key] = func; + return this; + }, + add_action(key, func) { + this.actions[key] = func; + return this; + }, + set_param(key, value) { + this.params[key] = value; + }, + remove_param(key, do_refresh = true) { + delete this.params[key]; + if (do_refresh) { + this.refresh(); + } + }, + sort_by(key, dir = null, do_refresh = true) { + let ths = this.container.getElementsByClassName("column-" + key); + if (ths.length > 0) { + this.order = dir ?? (this.order_by === key ? 1 - this.order : 0); + this.order_by = key; + + let th = ths[0]; + if (this.order === 0) { + th.classList.remove('asc'); + th.classList.add('desc'); + } else { + th.classList.remove('desc'); + th.classList.add('asc'); + } + + // if (th.classList.contains('asc')) { + // th.classList.replace('asc', 'desc'); + // this.set_param('order', 'desc'); + // } else { + // th.classList.remove('desc'); + // th.classList.add('asc'); + // this.set_param('order', 'asc'); + // } + let sorted = th.parentElement.getElementsByClassName('sorted'); + if (sorted.length > 0) + sorted[0].classList.replace('sorted', 'sortable'); + th.classList.replace('sortable', 'sorted'); + + if (do_refresh) { + this.refresh(); + } + } + + return this; + }, + set_total(count) { + this.total = count; + this.paging_controls.display_num.innerHTML = `${this.total} položek`; + this.paging_controls.paging_text.innerHTML = `${this.page + 1} of ${Math.ceil(count / this.page_size)}`; + }, + get_total() { + return this.total; + }, + move_page(relative) { + this.page = Math.max(0, Math.min(this.page + relative, Math.ceil(this.total / this.page_size) - 1)); + this.refresh(); + }, + goto_first_page() { + this.move_page(-this.page); + }, + goto_last_page() { + this.move_page(Math.ceil(this.total / this.page_size) - this.page - 1); + }, + }; + + let paging_controls = this.create_paging_controls(state); + + let footer = document.createElement('div'); + footer.classList.add('tablenav', 'bottom'); + footer.appendChild(paging_controls.container); + + state.paging_controls = paging_controls; + + let table = document.createElement('table'); + table.classList.add('datagrid', 'wp-list-table', 'widefat', 'fixed', 'striped', 'table-view-list'); + // const has_actions = Object.entries(actions).length > 0; + table.appendChild(this.create_header(state, columns, false)); + + table.appendChild(tbody); + + table.appendChild(this.create_footer(state, columns, false)); + + container.appendChild(table); + + container.appendChild(footer); + + return state; + } +} diff --git a/assets/js/elements/RsvReservationSelector.js b/assets/js/elements/RsvReservationSelector.js new file mode 100644 index 0000000..7f405a0 --- /dev/null +++ b/assets/js/elements/RsvReservationSelector.js @@ -0,0 +1,117 @@ +class RsvReservationSelector extends HTMLElement { + static get observedAttributes() { + return ['timetable-id', 'name', 'price-per-block']; + } + + // ---- Attribute accessors ------------------------------------------------ + + get timetableId() { return parseInt(this.getAttribute('timetable-id')); } + get inputName() { return this.getAttribute('name') ?? 'reservation'; } + get pricePerBlock() { return parseFloat(this.getAttribute('price-per-block')) || 0; } + + // ---- Lifecycle ---------------------------------------------------------- + + connectedCallback() { + this._slots = []; + this.classList.add('rsv-timetable-selector'); + this._build(); + } + + attributeChangedCallback(_attr, oldVal, newVal) { + if (oldVal === null || oldVal === newVal || !this.isConnected) return; + this._build(); + } + + // ---- Public API --------------------------------------------------------- + + getValue() { + return { + timetable_id: this.timetableId, + timetable_reservations: this._slots.map(s => s.start_utc), + }; + } + + clear() { + this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected')); + this._slots = []; + this._commit(); + } + + // ---- Private ------------------------------------------------------------ + + _build() { + this._slots = []; + this.replaceChildren(); + + const tid = document.createElement('input'); + tid.type = 'hidden'; + tid.name = `${this.inputName}.timetable_id`; + tid.value = this.timetableId; + this.appendChild(tid); + + const cal_el = document.createElement('div'); + cal_el.classList.add('rsv-calendar'); + + // Create rsv-timeline with timetable-id set before appending so + // connectedCallback sees the correct attribute on first render. + const time_el = document.createElement('rsv-timeline'); + time_el.setAttribute('timetable-id', this.timetableId); + + this.append(cal_el, time_el); + + this._calendar = RsvCalendarPicker.create(cal_el, this.inputName); + + // Date change: clear selection, then push new date to the timeline element. + cal_el.addEventListener('change', () => { + this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected')); + this._slots = []; + this._commit(); + time_el.date = this._calendar.date; + }); + + // Slot toggle: read selected slots from timeline, then commit. + time_el.addEventListener('input', e => { + e.stopPropagation(); + this._slots = Array.from(time_el.querySelectorAll('.rsv-slots-slot-selected')).map(s => ({ + start_utc: s.dataset.start_utc, + end_utc: s.dataset.end_utc, + })); + this._commit(); + }); + + this._commit(); + } + + _commit() { + const name = this.inputName; + + this.querySelectorAll(`input[name="${name}.timetable_reservations[]"]`).forEach(i => i.remove()); + + let json = []; + this._slots.forEach(slot => { + const inp = document.createElement('input'); + inp.type = 'hidden'; + inp.name = `${name}.timetable_reservations[]`; + inp.value = slot.start_utc; + this.appendChild(inp); + json.push(slot.start_utc); + }); + + this.value = JSON.stringify({ + "timetable_id": this.timetableId, + "timetable_reservations": json + }); + + this.dispatchEvent(new CustomEvent('rsv:slots-changed', { + bubbles: true, + detail: { + name, + slots: this._slots, + price_per_block: this.pricePerBlock, + value: this.getValue(), + }, + })); + } +} + +customElements.define('rsv-reservation-selector', RsvReservationSelector); diff --git a/assets/js/elements/RsvReservationSummary.js b/assets/js/elements/RsvReservationSummary.js new file mode 100644 index 0000000..f93373c --- /dev/null +++ b/assets/js/elements/RsvReservationSummary.js @@ -0,0 +1,100 @@ +class RsvReservationSummary extends HTMLElement { + + // ---- Lifecycle ---------------------------------------------------------- + + connectedCallback() { + this._all_slots = new Map(); // name → { slots, price_per_block } + this._form = this.closest('form'); + this._build(); + + if (this._form) { + this._handler = e => { + this._all_slots.set(e.detail.name, { + slots: e.detail.slots, + price_per_block: e.detail.price_per_block, + }); + this._render(); + }; + this._form.addEventListener('rsv:slots-changed', this._handler); + } + } + + disconnectedCallback() { + if (this._form && this._handler) { + this._form.removeEventListener('rsv:slots-changed', this._handler); + } + } + + // ---- Private ------------------------------------------------------------ + + _build() { + const s = ReservairStrings.summary; + this.innerHTML = ` +
+ ${s.title} + +
+ + + `; + this.hidden = true; + + this.querySelector('.rsv-summary-clear').addEventListener('click', () => { + this._form?.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.clear()); + }); + } + + _render() { + const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) => + slots.map(s => ({ ...s, price_per_block })) + ); + console.log(all_slots); + + const n = all_slots.length; + const list = this.querySelector('.rsv-summary-list'); + const count_el = this.querySelector('.rsv-summary-count'); + const price_el = this.querySelector('.rsv-summary-price'); + const s = ReservairStrings.summary; + const locale = navigator.language; + + this.hidden = n === 0; + if (n === 0) { + list.replaceChildren(); + count_el.textContent = ''; + price_el.textContent = ''; + return; + } + + const time_opts = { hour: '2-digit', minute: '2-digit' }; + list.replaceChildren(...all_slots.map(slot => { + const start = new Date(slot.start_utc); + const end = new Date(slot.end_utc); + const li = document.createElement('li'); + li.className = 'rsv-summary-item'; + li.innerHTML = ` +
+ ${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })} + ${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)} +
+ ${slot.price_per_block > 0 ? `${slot.price_per_block} ${s.currency}` : ''} + `; + return li; + })); + + const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0); + count_el.textContent = this._fmt_count(n); + price_el.textContent = total > 0 ? `${total} ${s.currency}` : ''; + } + + _fmt_count(n) { + const s = ReservairStrings.summary; + if (n === 1) return s.count_one; + if (n < 5) return s.count_few.replace('%d', n); + return s.count_many.replace('%d', n); + } +} + +customElements.define('rsv-reservation-summary', RsvReservationSummary); diff --git a/assets/js/elements/RsvTimeline.js b/assets/js/elements/RsvTimeline.js new file mode 100644 index 0000000..2ad7d01 --- /dev/null +++ b/assets/js/elements/RsvTimeline.js @@ -0,0 +1,141 @@ +class RsvTimeline extends HTMLElement { + static get observedAttributes() { + return ['timetable-id', 'date']; + } + + // ---- Attribute accessors ------------------------------------------------ + + get timetableId() { return parseInt(this.getAttribute('timetable-id')); } + + get date() { + const attr = this.getAttribute('date'); + // Parse as local midnight so setHours() in block rendering stays in local time. + return attr ? new Date(attr + 'T12:00:00') : new Date(); + } + + set date(value) { + const d = new Date(value); + const str = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + this.setAttribute('date', str); + } + + // ---- Lifecycle ---------------------------------------------------------- + + connectedCallback() { + this._version = 0; + this.classList.add('rsv-slots-list'); + this.addEventListener('click', this._on_click.bind(this)); + this._render(); + } + + attributeChangedCallback(_attr, oldVal, newVal) { + if (oldVal === newVal || !this.isConnected) return; + this._render(); + } + + // ---- Private ------------------------------------------------------------ + + _on_click(event) { + const slot = event.target.closest('.rsv-slots-slot'); + if (slot && !slot.classList.contains('rsv-slots-slot-full')) { + slot.classList.toggle('rsv-slots-slot-selected'); + slot.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + async _render() { + // Version guard: discard renders that were superseded by a newer call. + const v = ++this._version; + const s = ReservairStrings.timeline; + + if (this.timetableId === null) { + this.replaceChildren(this._notice(s.not_reservable)); + return; + } + + try { + const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date); + if (v !== this._version) return; + + if(occupancy.length === 0) { + this.replaceChildren(this._notice(s.no_blocks)); + return; + } + + const header = document.createElement('div'); + header.classList.add('rsv-slots-label'); + header.textContent = this.date.toLocaleDateString(navigator.language, { + weekday: 'long', day: 'numeric', month: 'long', + }).replace(',', ''); + + const blocks = []; + + for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) { + if (from_minutes === to_minutes || block_occ.length === 0) { + continue; + } + + const from_block = parseInt(from_minutes) / block_size_in_minutes; + + const time_slots = block_occ.map((occ, i) => + this._block(this.date, occ, block_size_in_minutes, from_block + i) + ); + + const time_slot_group = document.createElement('div'); + time_slot_group.classList.add('rsv-slots-group'); + time_slot_group.replaceChildren(...time_slots); + blocks.push(time_slot_group); + } + + this.replaceChildren(header, ...blocks); + } catch (_e) { + if (v !== this._version) return; + this.replaceChildren(this._notice(s.no_blocks)); + } + } + + _block(date, left, block_size, idx) { + const from = new Date(date); + from.setHours(0, idx * block_size, 0, 0); + + const to = new Date(from); + to.setMinutes(to.getMinutes() + block_size); + + const cell = document.createElement('div'); + cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available'); + cell.dataset.start_utc = from.toISOString(); + cell.dataset.end_utc = to.toISOString(); + if (left === 0) cell.classList.add('rsv-slots-slot-full'); + + const time_el = document.createElement('span'); + time_el.classList.add('rsv-slots-slot-time'); + time_el.textContent = `${this._fmt(from)} – ${this._fmt(to)}`; + + const badge = document.createElement('span'); + badge.classList.add('rsv-slots-slot-badge'); + const remaining_seats = left; + + if (remaining_seats > 0) badge.classList.add('rsv-slots-slot-badge-available'); + + if (remaining_seats === 1) badge.textContent = `${remaining_seats} místo`; + else if (remaining_seats >= 2 && remaining_seats <= 4) badge.textContent = `${remaining_seats} místa`; + else badge.textContent = `${remaining_seats} míst`; + + + cell.append(time_el, badge); + return cell; + } + + _notice(text) { + const p = document.createElement('p'); + p.classList.add('rsv-slots-notice'); + p.textContent = text; + return p; + } + + _fmt(dt) { + return dt.getHours() + ':' + String(dt.getMinutes()).padStart(2, '0'); + } +} + +customElements.define('rsv-timeline', RsvTimeline); diff --git a/assets/js/forms/RsvAdminForm.js b/assets/js/forms/RsvAdminForm.js new file mode 100644 index 0000000..a3e2d7d --- /dev/null +++ b/assets/js/forms/RsvAdminForm.js @@ -0,0 +1,57 @@ +/* + * RsvAdminForm — shared submit handler for wp-admin forms. + * + * Serializes a
to JSON (via RsvFormEncoder), sends it to the form's + * `action` using the HTTP verb in `data-method`, always attaches the REST + * nonce, and reports the outcome through show_notice(). The only part that + * legitimately differs between forms — shaping the request body — is handled + * by the optional `transform(body, form)` hook. + * + * Usage: + * RsvAdminForm.bind(my_form, { + * transform: (body, form) => ({ ...body, block_size: parseInt(body.block_size) }), + * refresh: () => my_datagrid.refresh(), + * }); + */ +const RsvAdminForm = { + // Attach a submit listener that sends the form as JSON. + bind(form, options = {}) { + if (!form) return; + form.addEventListener('submit', (event) => { + event.preventDefault(); + RsvAdminForm.submit(form, options); + }); + }, + + // Send the form now. Returns the fetch promise. + submit(form, { transform, refresh, onSuccess } = {}) { + let body = RsvFormEncoder.encode_form(form); + if (transform) body = transform(body, form); + + // `form.method` always returns a string (default 'get'), so default to POST + // explicitly unless the view opted into a verb via data-method. + const method = (form.dataset.method || 'POST').toUpperCase(); + + return fetch(form.action, { + method, + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-WP-Nonce': ReservairServiceAPI.nonce, + }, + body: JSON.stringify(body), + }) + .then(async (response) => { + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || data?.message || 'Request failed'); + return data; + }) + .then((data) => { + show_notice(form, 'success', form.dataset.successMsg ?? 'Saved.'); + if (refresh) refresh(); + if (onSuccess) onSuccess(data); + }) + .catch((error) => show_notice(form, 'error', error.message)); + }, +}; diff --git a/assets/js/forms/RsvFormEncoder.js b/assets/js/forms/RsvFormEncoder.js new file mode 100644 index 0000000..c824d59 --- /dev/null +++ b/assets/js/forms/RsvFormEncoder.js @@ -0,0 +1,41 @@ +const RsvFormEncoder = { + // Serialize form element into a plain JS object supporting arrays. + // - Nested keys supported with dot notation: 'meta.email' + // - Array notation supported with trailing [] (e.g. 'times[]') or multiple inputs with same name + encode_form(form_element) { + const formData = new FormData(form_element); + const body = {}; + + for (const [rawKey, value] of formData.entries()) { + const isArrayNotation = rawKey.endsWith('[]'); + const key = isArrayNotation ? rawKey.slice(0, -2) : rawKey; + const keys = key.split('.'); + + let current = body; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + if (current[k] === undefined || typeof current[k] !== 'object') { + current[k] = {}; + } + current = current[k]; + } + + const lastKey = keys[keys.length - 1]; + + if (isArrayNotation) { + if (!Array.isArray(current[lastKey])) current[lastKey] = []; + current[lastKey].push(value); + } else { + if (current[lastKey] === undefined) { + current[lastKey] = value; + } else if (Array.isArray(current[lastKey])) { + current[lastKey].push(value); + } else { + current[lastKey] = [current[lastKey], value]; + } + } + } + + return body; + } +} diff --git a/assets/js/forms/RsvFormSender.js b/assets/js/forms/RsvFormSender.js new file mode 100644 index 0000000..c707c9a --- /dev/null +++ b/assets/js/forms/RsvFormSender.js @@ -0,0 +1,142 @@ +const RsvFormSender = { + get_form_url(form_id) { + return ReservairServiceAPI.restUrl + '/form/' + form_id; + }, + + clear_feedback(form) { + form.querySelectorAll('.rsv-field-error').forEach(el => el.remove()); + form.querySelectorAll('.rsv-invalid').forEach(el => el.classList.remove('rsv-invalid')); + form.querySelector('.rsv-error-summary')?.remove(); + }, + + show_errors(form, errors) { + this.clear_feedback(form); + + const ul = document.createElement('ul'); + for (const err of errors) { + const li = document.createElement('li'); + li.textContent = err.message; + ul.appendChild(li); + + if (err.element) { + const field = form.querySelector(`[name="${err.element}"]`); + if (field) { + field.classList.add('rsv-invalid'); + const msg = document.createElement('span'); + msg.classList.add('rsv-field-error'); + msg.textContent = err.message; + field.insertAdjacentElement('afterend', msg); + } + } + } + + const summary = document.createElement('div'); + summary.classList.add('rsv-error-summary'); + summary.appendChild(ul); + form.prepend(summary); + }, + + show_success(form, _data) { + const s = ReservairStrings.form; + const wrapper = form.parentElement; + const existing = Array.from(wrapper.children); + + const svgNS = 'http://www.w3.org/2000/svg'; + const path = document.createElementNS(svgNS, 'path'); + path.setAttribute('d', 'M6 14l6 6L22 8'); + path.setAttribute('stroke', '#16a34a'); + path.setAttribute('stroke-width', '2.5'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('width', '28'); + svg.setAttribute('height', '28'); + svg.setAttribute('viewBox', '0 0 28 28'); + svg.setAttribute('fill', 'none'); + svg.appendChild(path); + + const icon = document.createElement('div'); + icon.className = 'success-icon'; + icon.appendChild(svg); + + const title = document.createElement('div'); + title.className = 'success-title'; + title.textContent = s.success_title; + + const subtitle = document.createElement('p'); + subtitle.className = 'success-msg'; + subtitle.textContent = s.success_subtitle; + + const reset_btn = document.createElement('button'); + reset_btn.className = 'reset-btn'; + reset_btn.textContent = s.new_reservation; + + const state = document.createElement('div'); + state.className = 'success-state'; + state.append(icon, title, subtitle, reset_btn); + + const msg = document.createElement('div'); + msg.appendChild(state); + + existing.forEach(child => child.style.display = 'none'); + wrapper.appendChild(msg); + + reset_btn.addEventListener('click', () => { + msg.remove(); + form.reset(); + this.clear_feedback(form); + existing.forEach(child => child.style.display = ''); + }); + }, + + set_loading(form, is_loading) { + const btn = form.querySelector('button[type="submit"], button:not([type])'); + if (!btn) return; + btn.disabled = is_loading; + btn.classList.toggle('rsv-loading', is_loading); + }, + + encode_to_json(form) { + const fields = form.querySelectorAll('.rsv-form-field'); + const body = {}; + fields.forEach(field => { + const name = field.name ?? field.getAttribute('name'); + try { + body[name] = JSON.parse(field.value); + } catch { + body[name] = field.value; + } + }); + return body; + }, + + send_form(event) { + event.preventDefault(); + const form = event.target; + this.clear_feedback(form); + this.set_loading(form, true); + const body = this.encode_to_json(form); + + fetch(this.get_form_url(form.id), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(async response => { + const data = await response.json().catch(() => null); + if (!response.ok) throw { status: response.status, body: data }; + return data; + }) + .then(data => { + this.show_success(form, data); + }) + .catch(error => { + const errors = error?.body?.errors + ?? [{ element: '', message: ReservairStrings.form.error_generic }]; + this.show_errors(form, errors); + }) + .finally(() => { + this.set_loading(form, false); + }); + }, +}; diff --git a/assets/js/forms/RsvInlineFormBuilder.js b/assets/js/forms/RsvInlineFormBuilder.js new file mode 100644 index 0000000..9d21f61 --- /dev/null +++ b/assets/js/forms/RsvInlineFormBuilder.js @@ -0,0 +1,229 @@ +const RsvInlineFormBuilder = { + match_p(name, value) { + return (form) => String(form[name]) === String(value); + }, + create(datasource) { + const fields = []; + + const builder = { + datasource: datasource, + fieldset(legend, width = null) { + fields.push({ type: 'fieldset', legend, width }); + return this; + }, + input_text(name, label, value = '') { + fields.push({ type: 'text', name, label, value }); + return this; + }, + input_number(name, label, value = '') { + fields.push({ type: 'number', name, label, value }); + return this; + }, + input_date(name, label, value = '') { + fields.push({ type: 'date', name, label, value }); + return this; + }, + input_time(name, label, value = '') { + fields.push({ type: 'time', name, label, value }); + return this; + }, + input_textarea(name, label, value = '') { + fields.push({ type: 'textarea', name, label, value }); + return this; + }, + input_checkbox(name, label, checked = false) { + fields.push({ type: 'checkbox', name, label, checked }); + return this; + }, + input_hidden(name, value) { + fields.push({ type: 'hidden', name, value }); + return this; + }, + input_select(name, label, options, value = '') { + fields.push({ type: 'select', name, label, options, value }); + return this; + }, + show_if(predicate) { + const last = fields[fields.length - 1]; + if (last) last.show_if = predicate; + return this; + }, + build({ id, colspan = 1, save_label = 'Update', on_success, on_cancel } = {}) { + const td = document.createElement('td'); + td.setAttribute('colspan', colspan); + + const form = document.createElement('form'); + const wrapper = document.createElement('div'); + wrapper.classList.add('inline-edit-wrapper'); + + const hidden_inputs = []; + let current_fieldset = null; + let current_col = null; + const fieldsets = []; + const conditionals = []; + + function ensure_fieldset() { + if (current_fieldset === null) { + current_fieldset = document.createElement('fieldset'); + current_col = document.createElement('div'); + current_col.classList.add('inline-edit-col'); + fieldsets.push(current_fieldset); + } + } + + for (const field of fields) { + if (field.type === 'hidden') { + hidden_inputs.push(field); + continue; + } + + if (field.type === 'fieldset') { + if (current_fieldset !== null) { + current_fieldset.appendChild(current_col); + } + current_fieldset = document.createElement('fieldset'); + if (field.width) current_fieldset.style.width = field.width; + + const legend_el = document.createElement('legend'); + legend_el.classList.add('inline-edit-legend'); + legend_el.innerText = field.legend; + current_fieldset.appendChild(legend_el); + + current_col = document.createElement('div'); + current_col.classList.add('inline-edit-col'); + fieldsets.push(current_fieldset); + continue; + } + + ensure_fieldset(); + + const label_el = document.createElement('label'); + + const title = document.createElement('span'); + title.classList.add('title'); + title.innerText = field.label; + + const wrap = document.createElement('span'); + wrap.classList.add('input-text-wrap'); + + let input; + if (field.type === 'select') { + input = document.createElement('select'); + input.name = field.name; + for (const opt of field.options) { + const option = document.createElement('option'); + if (typeof opt === 'object' && opt !== null) { + option.value = opt.value; + option.textContent = opt.label; + option.selected = String(opt.value) === String(field.value); + } else { + option.value = opt; + option.textContent = opt; + option.selected = opt === field.value; + } + input.appendChild(option); + } + } else if (field.type === 'textarea') { + input = document.createElement('textarea'); + input.name = field.name; + input.rows = 5; + input.style.width = '100%'; + input.value = field.value ?? ''; + } else { + input = document.createElement('input'); + input.type = field.type; + input.name = field.name; + if (field.type === 'checkbox') { + input.checked = field.checked; + } else { + input.value = field.value ?? ''; + } + } + + wrap.appendChild(input); + label_el.replaceChildren(title, wrap); + current_col.appendChild(label_el); + + if (field.show_if) conditionals.push({ label_el, predicate: field.show_if }); + } + + if (current_fieldset !== null) { + current_fieldset.appendChild(current_col); + } + + const save_row = document.createElement('div'); + save_row.classList.add('inline-edit-save', 'submit'); + + const error = document.createElement('div'); + error.classList.add('notice', 'notice-error', 'notice-alt', 'inline', 'hidden'); + const error_p = document.createElement('p'); + error_p.classList.add('error'); + error.appendChild(error_p); + + const spinner = document.createElement('span'); + spinner.classList.add('spinner'); + + const save_btn = document.createElement('button'); + save_btn.type = 'button'; + save_btn.classList.add('save', 'button', 'button-primary'); + save_btn.innerText = save_label; + save_btn.onclick = () => { + const form_data = Object.fromEntries(new FormData(form)); + for (const field of fields) { + if (field.type === 'checkbox') { + form_data[field.name] = field.name in form_data; + } + } + spinner.classList.add('is-active'); + error.classList.add('hidden'); + builder.datasource.put(id, form_data) + .then(() => { if (on_success) on_success(); }) + .catch(err => { + error_p.innerText = err.message; + error.classList.remove('hidden'); + }) + .finally(() => spinner.classList.remove('is-active')); + }; + + const cancel_btn = document.createElement('button'); + cancel_btn.type = 'button'; + cancel_btn.classList.add('cancel', 'button'); + cancel_btn.innerText = 'Cancel'; + if (on_cancel) cancel_btn.onclick = on_cancel; + + save_row.replaceChildren(save_btn, cancel_btn, spinner, error); + + for (const h of hidden_inputs) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = h.name; + input.value = h.value ?? ''; + form.appendChild(input); + } + + wrapper.replaceChildren(...fieldsets, save_row); + form.appendChild(wrapper); + td.appendChild(form); + + if (conditionals.length) { + const snapshot = () => { + const f = Object.fromEntries(new FormData(form)); + for (const field of fields) if (field.type === 'checkbox') f[field.name] = field.name in f; + return f; + }; + const sync_all = () => { + const f = snapshot(); + for (const c of conditionals) c.label_el.classList.toggle('hidden', !c.predicate(f)); + }; + form.addEventListener('change', sync_all); + form.addEventListener('input', sync_all); + sync_all(); + } + + return td; + }, + }; + + return builder; + }, +}; diff --git a/assets/js/services/RsvTimetableService.js b/assets/js/services/RsvTimetableService.js new file mode 100644 index 0000000..ce81b74 --- /dev/null +++ b/assets/js/services/RsvTimetableService.js @@ -0,0 +1,21 @@ +const RsvTimetableService = { + get_all() { + return fetch(get_rest_url('timetable'), { method: 'GET' }) + .then(r => { + if (!r.ok) throw new Error(`fetch timetables failed: ${r.status}`); + return r.json(); + }); + }, + + get_availability_for_date(timetable_id, date) { + const params = new URLSearchParams({ + date: date.toISOString().slice(0, 10), + }); + + return fetch(get_rest_url(`timetable/${timetable_id}/availability?${params}`), { method: 'GET' }) + .then(r => { + if (!r.ok) throw new Error(`fetch availability failed: ${r.status}`); + return r.json(); + }); + } +} diff --git a/bin/build-zip.sh b/bin/build-zip.sh new file mode 100755 index 0000000..0b69e1b --- /dev/null +++ b/bin/build-zip.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Build an installable WordPress plugin ZIP for Reservair. +# +# Produces dist/reservair-.zip containing a single top-level +# `reservair/` directory with ONLY the runtime files: the compiled Gutenberg +# block assets and production-only Composer dependencies. +# +# The build runs in an isolated temp directory, so it never touches your +# working-tree vendor/ or node_modules/ — safe to run locally or in CI. +# +# Usage: +# ./bin/build-zip.sh +# +# Environment overrides: +# OUTPUT_DIR directory the final zip is written to (default: /dist) +# SKIP_JS "1" to reuse the existing build/ dir (default: build fresh) +# KEEP_TMP "1" to keep the temp build dir for debug (default: removed) +# +set -euo pipefail + +# --- locate repo root (this script lives in /bin) ---------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +SLUG="reservair" +OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/dist}" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# --- prerequisites ----------------------------------------------------------- +need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed"; } +need composer +need zip +need rsync +[ "${SKIP_JS:-0}" = "1" ] || need npm + +# --- read version from the plugin header (single source of truth) ------------ +# `read` consumes only the first match and avoids a `| head` pipe (which would +# SIGPIPE the upstream sed under `pipefail` and abort the script with code 141). +VERSION="" +read -r VERSION < <(sed -n 's/^[[:space:]]*\*\{0,1\}[[:space:]]*Version:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$ROOT_DIR/$SLUG.php") || true +[ -n "$VERSION" ] || die "could not read 'Version:' header from $SLUG.php" +log "Packaging $SLUG version $VERSION" + +# --- isolated build dir (never touches your working vendor/ or node_modules) - +TMP_DIR="$(mktemp -d)" +BUILD_DIR="$TMP_DIR/src" +STAGE_DIR="$TMP_DIR/stage/$SLUG" +cleanup() { [ "${KEEP_TMP:-0}" = "1" ] || rm -rf "$TMP_DIR"; } +trap cleanup EXIT +mkdir -p "$BUILD_DIR" "$STAGE_DIR" + +# --- copy the source tree into the isolated build dir ------------------------ +log "Copying source into isolated build dir" +rsync -a \ + --exclude='.git/' \ + --exclude='node_modules/' \ + --exclude='vendor/' \ + --exclude='build/' \ + --exclude='dist/' \ + --exclude='.wp-env.json' \ + ./ "$BUILD_DIR/" + +# --- compile the Gutenberg block (src/ -> build/) ---------------------------- +if [ "${SKIP_JS:-0}" = "1" ]; then + [ -d "$ROOT_DIR/build" ] || die "SKIP_JS=1 but no build/ exists — run 'npm run build' first" + log "SKIP_JS=1 — reusing existing build/" + cp -R "$ROOT_DIR/build" "$BUILD_DIR/build" +else + log "Installing JS deps and building block assets" + ( cd "$BUILD_DIR" && npm ci && npm run build ) +fi + +# --- install production-only PHP deps + regenerate autoloader ---------------- +# This strips psalm/phpstan/wordpress-core etc. and rebuilds the classmap that +# loads the plugin's own includes/ classes — vendor/ is required at runtime. +log "Installing production Composer dependencies" +( cd "$BUILD_DIR" && composer install \ + --no-dev --optimize-autoloader --classmap-authoritative \ + --no-interaction --no-progress ) + +# --- stage only the runtime files ------------------------------------------- +log "Staging runtime files" +RUNTIME=( "$SLUG.php" admin.php uninstall.php readme.txt includes modules build assets vendor ) +for item in "${RUNTIME[@]}"; do + if [ -e "$BUILD_DIR/$item" ]; then + cp -R "$BUILD_DIR/$item" "$STAGE_DIR/" + else + log " (skipping missing $item)" + fi +done + +# --- fail loudly if anything essential is missing ---------------------------- +for required in "$SLUG.php" vendor/autoload.php build/block.json; do + [ -e "$STAGE_DIR/$required" ] || die "packaging incomplete: missing $required" +done + +# --- zip it ------------------------------------------------------------------ +mkdir -p "$OUTPUT_DIR" +ZIP_PATH="$OUTPUT_DIR/${SLUG}-${VERSION}.zip" +rm -f "$ZIP_PATH" +log "Creating $ZIP_PATH" +( cd "$TMP_DIR/stage" && zip -rq "$ZIP_PATH" "$SLUG" -x '*.DS_Store' ) + +log "Done → $ZIP_PATH" +# `|| true` keeps an early-closing `head` (SIGPIPE) from failing the script. +unzip -l "$ZIP_PATH" | tail -n +2 | head -n 12 || true diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5ec36f7 --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "require": { + "chillerlan/php-qrcode": "^5.0" + }, + "autoload": { + "psr-4": { + "Reservair\\": "modules/" + }, + "classmap": [ + "includes/" + ], + "files": [ + "includes/RsvAdminMenuDefinition.php", + "includes/RsvAssetsDefinition.php", + "includes/RsvRestApiDefinition.php", + "includes/Views/RsvFormsPage.php", + "includes/Views/RsvReservationsPage.php", + "includes/Views/RsvTimetablePage.php", + "includes/Views/RsvGoogleCalendarSettingsPage.php" + ] + }, + "require-dev": { + "johnpbloch/wordpress-core": "^6.9", + "vimeo/psalm": "^6.16", + "humanmade/psalm-plugin-wordpress": "^3.1" + }, + "scripts": { + "lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..c5069f1 --- /dev/null +++ b/composer.lock @@ -0,0 +1,3945 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c4e0cf49edc636becbde269300f26001", + "packages": [ + { + "name": "chillerlan/php-qrcode", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/42e215640e9ebdd857570c9e4e52245d1ee51de2", + "reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.4.5", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.15", + "squizlabs/php_codesniffer": "^3.11" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2024-11-21T16:12:34+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-07-16T11:13:48+00:00" + } + ], + "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/parallel", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/parallel.git", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", + "keywords": [ + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], + "support": { + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-16T16:54:01+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "a044733e080940d1483f56caff0c412ad6982776" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-06T05:37:57+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-19T15:09:56+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "danog/advanced-json-rpc", + "version": "v3.2.3", + "source": { + "type": "git", + "url": "https://github.com/danog/php-advanced-json-rpc.git", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91", + "shasum": "" + }, + "require": { + "netresearch/jsonmapper": "^5", + "php": ">=8.1", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6" + }, + "replace": { + "felixfbecker/php-advanced-json-rpc": "^3" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "AdvancedJsonRpc\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Felix Becker", + "email": "felix.b@outlook.com" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" + } + ], + "description": "A more advanced JSONRPC implementation", + "support": { + "issues": "https://github.com/danog/php-advanced-json-rpc/issues", + "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.3" + }, + "time": "2026-01-12T21:07:10+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "dnoegel/php-xdg-base-dir", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/dnoegel/php-xdg-base-dir.git", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "XdgBaseDir\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "implementation of xdg base directory specification for php", + "support": { + "issues": "https://github.com/dnoegel/php-xdg-base-dir/issues", + "source": "https://github.com/dnoegel/php-xdg-base-dir/tree/v0.1.1" + }, + "time": "2019-12-04T15:06:13+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "felixfbecker/language-server-protocol", + "version": "v1.5.3", + "source": { + "type": "git", + "url": "https://github.com/felixfbecker/php-language-server-protocol.git", + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/a9e113dbc7d849e35b8776da39edaf4313b7b6c9", + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/phpstan": "*", + "squizlabs/php_codesniffer": "^3.1", + "vimeo/psalm": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "LanguageServerProtocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Felix Becker", + "email": "felix.b@outlook.com" + } + ], + "description": "PHP classes for the Language Server Protocol", + "keywords": [ + "language", + "microsoft", + "php", + "server" + ], + "support": { + "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues", + "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.3" + }, + "time": "2024-04-30T00:40:11+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "humanmade/psalm-plugin-wordpress", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/psalm/psalm-plugin-wordpress.git", + "reference": "3f4689ad5264eee7b37121053cec810a3754f7e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/psalm/psalm-plugin-wordpress/zipball/3f4689ad5264eee7b37121053cec810a3754f7e4", + "reference": "3f4689ad5264eee7b37121053cec810a3754f7e4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php-stubs/wordpress-globals": "^0.2.0", + "php-stubs/wordpress-stubs": "^6.0", + "php-stubs/wp-cli-stubs": "^2.7", + "vimeo/psalm": "^5 || ^6", + "wp-hooks/wordpress-core": "^1.3.0" + }, + "require-dev": { + "humanmade/coding-standards": "^1.2", + "phpunit/phpunit": "^9.0", + "psalm/plugin-phpunit": "^0.18.4" + }, + "type": "psalm-plugin", + "extra": { + "psalm": { + "pluginClass": "PsalmWordPress\\Plugin" + } + }, + "autoload": { + "psr-4": { + "PsalmWordPress\\": [ + "." + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "kkmuffme", + "role": "Maintainer" + }, + { + "name": "Joe Hoyle", + "role": "Creator" + } + ], + "description": "WordPress stubs and plugin for Psalm static analysis.", + "support": { + "issues": "https://github.com/psalm/psalm-plugin-wordpress/issues", + "source": "https://github.com/psalm/psalm-plugin-wordpress" + }, + "time": "2024-04-01T10:36:11+00:00" + }, + { + "name": "johnpbloch/wordpress-core", + "version": "6.9.0", + "source": { + "type": "git", + "url": "https://github.com/johnpbloch/wordpress-core.git", + "reference": "4626d4e896c36ab77a69ce58627bc76243b5dd07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/johnpbloch/wordpress-core/zipball/4626d4e896c36ab77a69ce58627bc76243b5dd07", + "reference": "4626d4e896c36ab77a69ce58627bc76243b5dd07", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.2.24" + }, + "provide": { + "wordpress/core-implementation": "6.9.0" + }, + "type": "wordpress-core", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "forum": "https://wordpress.org/support/", + "irc": "irc://irc.freenode.net/wordpress", + "issues": "https://core.trac.wordpress.org/", + "source": "https://core.trac.wordpress.org/browser", + "wiki": "https://codex.wordpress.org/" + }, + "time": "2025-12-02T19:10:58+00:00" + }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "netresearch/jsonmapper", + "version": "v5.0.1", + "source": { + "type": "git", + "url": "https://github.com/cweiske/jsonmapper.git", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JsonMapper": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "cweiske@cweiske.de", + "homepage": "http://github.com/cweiske/jsonmapper/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "cweiske@cweiske.de", + "issues": "https://github.com/cweiske/jsonmapper/issues", + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" + }, + "time": "2026-02-22T16:28:03+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "php-stubs/wordpress-globals", + "version": "v0.2.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-globals.git", + "reference": "748a1fb2ae8fda94844bd0545935095dbf404b32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-globals/zipball/748a1fb2ae8fda94844bd0545935095dbf404b32", + "reference": "748a1fb2ae8fda94844bd0545935095dbf404b32", + "shasum": "" + }, + "require-dev": { + "php": "~7.1" + }, + "suggest": { + "php-stubs/wordpress-stubs": "Up-to-date WordPress function and class declaration stubs", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Global variables and global constants from WordPress core.", + "homepage": "https://github.com/php-stubs/wordpress-globals", + "keywords": [ + "PHPStan", + "constants", + "globals", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-globals/issues", + "source": "https://github.com/php-stubs/wordpress-globals/tree/master" + }, + "time": "2020-01-13T06:12:59+00:00" + }, + { + "name": "php-stubs/wordpress-stubs", + "version": "v6.9.4", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5", + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5", + "shasum": "" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "5.6.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "nikic/php-parser": "^5.5", + "php": "^7.4 || ^8.0", + "php-stubs/generator": "^0.8.6", + "phpdocumentor/reflection-docblock": "^6.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.5", + "symfony/polyfill-php80": "*", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4" + }, + "time": "2026-05-01T20:36:01+00:00" + }, + { + "name": "php-stubs/wp-cli-stubs", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wp-cli-stubs.git", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/af16401e299a3fd2229bd0fa9a037638a4174a9d", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d", + "shasum": "" + }, + "require": { + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0" + }, + "require-dev": { + "php": "~7.3 || ~8.0", + "php-stubs/generator": "^0.8.0" + }, + "suggest": { + "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WP-CLI function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wp-cli-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress", + "wp-cli" + ], + "support": { + "issues": "https://github.com/php-stubs/wp-cli-stubs/issues", + "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.12.0" + }, + "time": "2025-06-10T09:58:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-05-15T04:58:09+00:00" + }, + { + "name": "spatie/array-to-xml", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/array-to-xml.git", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "pestphp/pest": "^1.21", + "spatie/pest-plugin-snapshots": "^1.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Spatie\\ArrayToXml\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://freek.dev", + "role": "Developer" + } + ], + "description": "Convert an array to xml", + "homepage": "https://github.com/spatie/array-to-xml", + "keywords": [ + "array", + "convert", + "xml" + ], + "support": { + "source": "https://github.com/spatie/array-to-xml/tree/3.4.4" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-12-15T09:00:41+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "vimeo/psalm", + "version": "6.16.1", + "source": { + "type": "git", + "url": "https://github.com/vimeo/psalm.git", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parallel": "^2.3", + "composer-runtime-api": "^2", + "composer/semver": "^1.4 || ^2.0 || ^3.0", + "composer/xdebug-handler": "^2.0 || ^3.0", + "danog/advanced-json-rpc": "^3.1", + "dnoegel/php-xdg-base-dir": "^0.1.1", + "ext-ctype": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-tokenizer": "*", + "felixfbecker/language-server-protocol": "^1.5.3", + "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", + "netresearch/jsonmapper": "^5.0", + "nikic/php-parser": "^5.0.0", + "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "spatie/array-to-xml": "^2.17.0 || ^3.0", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", + "symfony/polyfill-php84": "^1.31.0" + }, + "provide": { + "psalm/psalm": "self.version" + }, + "require-dev": { + "amphp/phpunit-util": "^3", + "bamarni/composer-bin-plugin": "^1.4", + "brianium/paratest": "^6.9", + "danog/class-finder": "^0.4.8", + "dg/bypass-finals": "^1.5", + "ext-curl": "*", + "mockery/mockery": "^1.5", + "nunomaduro/mock-final-classes": "^1.1", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpdoc-parser": "^1.6", + "phpunit/phpunit": "^9.6", + "psalm/plugin-mockery": "^1.1", + "psalm/plugin-phpunit": "^0.19", + "slevomat/coding-standard": "^8.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/process": "^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-curl": "In order to send data to shepherd", + "ext-igbinary": "^2.0.5 is required, used to serialize caching data" + }, + "bin": [ + "psalm", + "psalm-language-server", + "psalm-plugin", + "psalm-refactor", + "psalm-review", + "psalter" + ], + "type": "project", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-6.x": "6.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psalm\\": "src/Psalm/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthew Brown" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" + } + ], + "description": "A static analysis tool for finding errors in PHP applications", + "keywords": [ + "code", + "inspection", + "php", + "static analysis" + ], + "support": { + "docs": "https://psalm.dev/docs", + "issues": "https://github.com/vimeo/psalm/issues", + "source": "https://github.com/vimeo/psalm" + }, + "time": "2026-03-19T10:56:09+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.0" + }, + "time": "2026-05-20T13:07:01+00:00" + }, + { + "name": "wp-hooks/wordpress-core", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/wp-hooks/wordpress-core-hooks.git", + "reference": "0ba438bdd4c99b6613eb8459feb0a4f6d2d7082c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-hooks/wordpress-core-hooks/zipball/0ba438bdd4c99b6613eb8459feb0a4f6d2d7082c", + "reference": "0ba438bdd4c99b6613eb8459feb0a4f6d2d7082c", + "shasum": "" + }, + "replace": { + "johnbillion/wp-hooks": "*" + }, + "require-dev": { + "erusev/parsedown": "1.8.0-beta-7", + "oomphinc/composer-installers-extender": "^2", + "roots/wordpress-core-installer": "^1.0.0", + "roots/wordpress-full": "7.0", + "wp-hooks/generator": "1.0.0" + }, + "type": "library", + "extra": { + "wp-hooks": { + "ignore-files": [ + "wp-admin/includes/deprecated.php", + "wp-admin/includes/ms-deprecated.php", + "wp-content/", + "wp-includes/build/pages/", + "wp-includes/deprecated.php", + "wp-includes/ID3/", + "wp-includes/ms-deprecated.php", + "wp-includes/pomo/", + "wp-includes/random_compat/", + "wp-includes/Requests/", + "wp-includes/SimplePie/", + "wp-includes/sodium_compat/", + "wp-includes/Text/" + ], + "ignore-hooks": [ + "load-categories.php", + "load-edit-link-categories.php", + "load-edit-tags.php", + "load-page-new.php", + "load-page.php", + "option_enable_xmlrpc", + "edit_post_{$field}", + "pre_post_{$field}", + "post_{$field}", + "pre_option_enable_xmlrpc", + "$page_hook", + "$hook", + "$hook_name" + ] + }, + "wordpress-install-dir": "vendor/wordpress/wordpress" + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "John Blackbourn", + "homepage": "https://johnblackbourn.com/" + } + ], + "description": "All the actions and filters from WordPress core in machine-readable JSON format.", + "support": { + "issues": "https://github.com/wp-hooks/wordpress-core-hooks/issues", + "source": "https://github.com/wp-hooks/wordpress-core-hooks/tree/1.12.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/johnbillion", + "type": "github" + } + ], + "time": "2026-05-22T10:28:41+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/includes/Controllers/RsvControllerConstants.php b/includes/Controllers/RsvControllerConstants.php new file mode 100644 index 0000000..a88da21 --- /dev/null +++ b/includes/Controllers/RsvControllerConstants.php @@ -0,0 +1,3 @@ +namespace = 'reservations/v1'; + $this->resource_name = 'form'; + } + + public function register_routes(): void { + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P[^/]+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'handle'], + // Public: site visitors submit reservation forms. The handler validates + // the form definition and payload before persisting anything. + 'permission_callback' => [RsvRestPolicy::class, 'open'] + ]); + } + + function handle(WP_REST_Request $request) { + $submitter = new RsvFormSubmission(); + $submit_result = $submitter->submit($request->get_param("id"), $request->get_json_params()); + + if(isset($submit_result['success']) && $submit_result['success'] === true) { + return new WP_REST_Response($submit_result, 200); + } + + return new WP_REST_Response($submit_result, 400); + } +} diff --git a/includes/Controllers/RsvFormDefinitionController.php b/includes/Controllers/RsvFormDefinitionController.php new file mode 100644 index 0000000..4bfd319 --- /dev/null +++ b/includes/Controllers/RsvFormDefinitionController.php @@ -0,0 +1,119 @@ + 'object', + 'properties' => [ + 'form_id' => ['type' => 'integer', 'readonly' => true], + 'name' => ['type' => 'string', 'required' => true, 'minLength' => 1], + 'definition' => [ + 'type' => 'object', + 'required' => false, + 'properties' => [ + 'email_key' => ['type' => 'string', 'required' => false], + 'elements' => ['type' => 'array', 'default' => []], + ], + ], + ], + ]; + } + + public function register_routes(): void { + register_rest_route($this->namespace, '/' . $this->resource_name, [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'index'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(self::schema()), + ], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'show'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'PUT', + 'callback' => [$this, 'update'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(self::schema()), + ], + [ + 'methods' => 'DELETE', + 'callback' => [$this, 'destroy'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + ]); + } + + function index(WP_REST_Request $request): WP_REST_Response { + [$skip, $limit] = self::paging($request); + $repo = new RsvFormDefinitionRepository(); + return $this->paged_response($repo->get_all($limit, $skip), $repo->count_all()); + } + + function show(WP_REST_Request $request): WP_REST_Response { + $row = (new RsvFormDefinitionRepository())->get((int) $request->get_param('id')); + + if ($row === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + return new WP_REST_Response($row, 200); + } + + function create(WP_REST_Request $request): WP_REST_Response { + try { + $id = (new RsvFormDefinitionRepository())->add( + $request->get_param('name'), + $request->get_param('definition') ?? [] + ); + } catch(Throwable $e) { + Logger::error($e); + return new WP_REST_Response(['error' => 'An error occurred.'], 500); + } + + return new WP_REST_Response(['id' => $id], 201); + } + + function destroy(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $repo = new RsvFormDefinitionRepository(); + + if ($repo->get($id) === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + $repo->delete($id); + + return new WP_REST_Response(null, 204); + } + + function update(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $repo = new RsvFormDefinitionRepository(); + + if ($repo->get($id) === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + $repo->update($id, $request->get_param('name'), $request->get_param('definition')); + + return new WP_REST_Response(null, 204); + } +} diff --git a/includes/Controllers/RsvPagedResponseTrait.php b/includes/Controllers/RsvPagedResponseTrait.php new file mode 100644 index 0000000..2429ebf --- /dev/null +++ b/includes/Controllers/RsvPagedResponseTrait.php @@ -0,0 +1,31 @@ + $total ?? count($data), + 'data' => $data, + ], 200); + } + + /** + * Read pagination from the request as [skip, limit]. limit is clamped to + * 1..100 and defaults to 20; skip defaults to 0. + * + * @return array{0:int,1:int} + */ + private static function paging(WP_REST_Request $request): array { + $skip = max(0, (int) $request->get_param('skip')); + $limit = (int) $request->get_param('limit'); + $limit = $limit > 0 ? min($limit, 100) : 20; + return [$skip, $limit]; + } + + /** Extract writable (non-readonly) properties from a schema for use as route args. */ + private static function input_args(array $schema): array { + return array_filter( + $schema['properties'], + fn(array $prop): bool => empty($prop['readonly']) + ); + } +} diff --git a/includes/Controllers/RsvReservationController.php b/includes/Controllers/RsvReservationController.php new file mode 100644 index 0000000..640cdbc --- /dev/null +++ b/includes/Controllers/RsvReservationController.php @@ -0,0 +1,87 @@ +namespace = 'reservations/v1'; + $this->resource_name = 'reservation'; + } + + public function register_routes(): void { + register_rest_route($this->namespace, '/' . $this->resource_name, [ + 'methods' => 'GET', + 'callback' => [$this, 'get_all'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'] + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'] + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name, [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'] + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/accept', [ + 'methods' => 'POST', + 'callback' => [$this, 'accept_by_id'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/refuse', [ + 'methods' => 'POST', + 'callback' => [$this, 'refuse_by_id'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + } + + function get_all(WP_REST_Request $request) { + [$skip, $limit] = self::paging($request); + $service = new RsvReservationService(); + return $this->paged_response((array) $service->get_all($limit, $skip), $service->count_all()); + } + + function get(WP_REST_Request $request): WP_REST_Response { + $service = new RsvReservationService(); + $detail = $service->get_detail((int) $request->get_param('id')); + + if ($detail === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + return new WP_REST_Response($detail, 200); + } + + function create(WP_REST_Request $request) { + $service = new RsvReservationService(); + $body = $request->get_json_params(); + return $service->create(RsvReservation::from_array($body)); + } + + function accept_by_id(WP_REST_Request $request): WP_REST_Response { + try { + (new RsvTimetableReservationService())->accept_by_reservation_id((int) $request->get_param('id')); + return new WP_REST_Response(['status' => 'accepted'], 200); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => $e->getMessage()], 404); + } + } + + function refuse_by_id(WP_REST_Request $request): WP_REST_Response { + try { + (new RsvTimetableReservationService())->refuse_by_reservation_id((int) $request->get_param('id')); + return new WP_REST_Response(['status' => 'refused'], 200); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => $e->getMessage()], 404); + } + } +} diff --git a/includes/Controllers/RsvRestPolicy.php b/includes/Controllers/RsvRestPolicy.php new file mode 100644 index 0000000..affa415 --- /dev/null +++ b/includes/Controllers/RsvRestPolicy.php @@ -0,0 +1,38 @@ + rest_authorization_required_code() ] + ); + } + + /** Public endpoints, and capability URLs validated inside the handler. */ + public static function open(): bool { + return true; + } +} diff --git a/includes/Controllers/RsvTimetableAvailabilityController.php b/includes/Controllers/RsvTimetableAvailabilityController.php new file mode 100644 index 0000000..8576071 --- /dev/null +++ b/includes/Controllers/RsvTimetableAvailabilityController.php @@ -0,0 +1,38 @@ +namespace, '/timetable/(?P\d+)/availability', [ + 'methods' => 'GET', + 'callback' => [$this, 'show'], + // Public: the booking widget reads availability for anonymous visitors. + 'permission_callback' => [RsvRestPolicy::class, 'open'], + 'args' => [ + 'date' => ['type' => 'string', 'required' => true, 'format' => 'date'], + ], + ]); + } + + public function show(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $service = new RsvTimetableService(); + $timetable = $service->get($id); + + if ($timetable === null || $timetable->id === null) { + return new WP_REST_Response(['error' => 'Timetable not found'], 404); + } + + try { + $availability = $service->get_availability_on_date($id, $timetable->block_size, new DateTime($request->get_param('date'))); + } catch (Throwable $e) { + Logger::error($e); + return new WP_REST_Response(['error' => $e->getMessage()], 400); + } + + return new WP_REST_Response($availability, 200); + } +} diff --git a/includes/Controllers/RsvTimetableCapacityController.php b/includes/Controllers/RsvTimetableCapacityController.php new file mode 100644 index 0000000..3552378 --- /dev/null +++ b/includes/Controllers/RsvTimetableCapacityController.php @@ -0,0 +1,114 @@ +\d+)/capacity'; + + public function register_routes(): void { + register_rest_route($this->namespace, '/' . $this->resource_name, [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'get_all'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + // 'args' => self::input_args(RsvTimetableCapacity::schema()), + ], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'get'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'PUT', + 'callback' => [$this, 'update'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(RsvTimetableCapacity::schema()), + ], + [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + ]); + } + + public function get_all(WP_REST_Request $request): WP_REST_Response { + [$skip, $limit] = self::paging($request); + $timetable_id = (int) $request->get_param('id'); + $service = new RsvTimetableCapacityRepository(); + return $this->paged_response( + $service->get_all($timetable_id, $limit, $skip), + $service->count_all($timetable_id) + ); + } + + public function get(WP_REST_Request $request): WP_REST_Response { + return new WP_REST_Response( + (new RsvTimetableCapacityRepository())->get((int) $request->get_param('capacity_id')), + 200 + ); + } + + public function create(WP_REST_Request $request): WP_REST_Response { + $items = $request->get_json_params(); + $timetable_id = (int) $request->get_param('id'); + + $ids = []; + + foreach($items as $item) { + $capacity = new RsvTimetableCapacity( + null, + $timetable_id, + (int) $item['capacity'], + (int) $item['min_lead_time_minutes'], + new DateTime($item['date']), + (int) $item['start_time'], + (int) $item['end_time'], + (int) $item['repeat_period_in_days'], + (int) $item['repeat_times'], + (bool) $item['requires_confirmation'], + ); + + $ids[] = (new RsvTimetableCapacityRepository())->create($capacity); + } + + return new WP_REST_Response( + ['ids' => $ids], + 201 + ); + } + + public function update(WP_REST_Request $request): WP_REST_Response { + $capacity = new RsvTimetableCapacity( + (int) $request->get_param('capacity_id'), + (int) $request->get_param('id'), + (int) $request->get_param('capacity'), + (int) $request->get_param('min_lead_time_minutes'), + new DateTime($request->get_param('date')), + (int)$request->get_param('start_time'), + (int)$request->get_param('end_time'), + (int) $request->get_param('repeat_period_in_days'), + (int) $request->get_param('repeat_times'), + (bool) $request->get_param('requires_confirmation'), + ); + + $capacity_id = (int) $request->get_param('capacity_id'); + (new RsvTimetableCapacityRepository())->update($capacity_id, $capacity); + + return new WP_REST_Response(['id' => $capacity_id], 200); + } + + public function delete(WP_REST_Request $request): WP_REST_Response { + (new RsvTimetableCapacityRepository())->delete((int) $request->get_param('capacity_id')); + return new WP_REST_Response(null, 204); + } +} diff --git a/includes/Controllers/RsvTimetableDefinitionController.php b/includes/Controllers/RsvTimetableDefinitionController.php new file mode 100644 index 0000000..723acfb --- /dev/null +++ b/includes/Controllers/RsvTimetableDefinitionController.php @@ -0,0 +1,88 @@ +namespace, '/' . $this->resource_name, [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'index'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(RsvTimetable::schema()), + ], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ + [ + 'methods' => 'PATCH', + 'callback' => [$this, 'update'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(RsvTimetable::schema()), + ], + [ + 'methods' => 'DELETE', + 'callback' => [$this, 'destroy'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + ]); + } + + public function index(WP_REST_Request $request): WP_REST_Response { + [$skip, $limit] = self::paging($request); + $service = new RsvTimetableService(); + return $this->paged_response($service->get_all($limit, $skip), $service->count_all()); + } + + public function create(WP_REST_Request $request): WP_REST_Response { + $service = new RsvTimetableService(); + $id = $service->create(new RsvTimetable([ + 'name' => $request->get_param('name'), + 'block_size' => (int) $request->get_param('block_size'), + 'maintainer_email' => $request->get_param('maintainer_email'), + ])); + + return new WP_REST_Response(['id' => $id], 201); + } + + public function update(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $service = new RsvTimetableService(); + $body = $request->get_json_params(); + + $timetable = $service->get($id); + if ($timetable === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + if (array_key_exists('name', $body)) $timetable->name = $body['name']; + if (array_key_exists('block_size', $body)) $timetable->block_size = (int) $body['block_size']; + if (array_key_exists('maintainer_email', $body)) $timetable->maintainer_email = $body['maintainer_email'] ?: null; + if (array_key_exists('google_calendar_id', $body)) $timetable->google_calendar_id = $body['google_calendar_id'] ?: null; + + $service->update($id, $timetable); + + return new WP_REST_Response(['id' => $id], 200); + } + + public function destroy(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $service = new RsvTimetableService(); + + if ($service->get($id) === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + $service->delete($id); + + return new WP_REST_Response(null, 204); + } +} diff --git a/includes/Controllers/RsvTimetableReservationController.php b/includes/Controllers/RsvTimetableReservationController.php new file mode 100644 index 0000000..abec8b1 --- /dev/null +++ b/includes/Controllers/RsvTimetableReservationController.php @@ -0,0 +1,62 @@ +\d+)/reservation'; + + public function register_routes(): void { + register_rest_route($this->namespace, $this->resource_name, [ + 'methods' => 'GET', + 'callback' => [$this, 'by_timetable'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + + register_rest_route($this->namespace, '/timetable-reservation/accept/(?P[a-zA-Z0-9]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'accept'], + // Capability URL: authorised by the secret confirmation code, which + // accept() validates against the database before changing state. + 'permission_callback' => [RsvRestPolicy::class, 'open'], + ]); + + register_rest_route($this->namespace, '/timetable-reservation/refuse/(?P[a-zA-Z0-9]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'refuse'], + // Capability URL: authorised by the secret confirmation code, which + // refuse() validates against the database before changing state. + 'permission_callback' => [RsvRestPolicy::class, 'open'], + ]); + } + + public function by_timetable(WP_REST_Request $request): WP_REST_Response { + [$skip, $limit] = self::paging($request); + $timetable_id = (int) $request->get_param('id'); + $service = new RsvTimetableReservationService(); + return $this->paged_response( + $service->get_by_timetable($timetable_id, $limit, $skip), + $service->count_by_timetable($timetable_id) + ); + } + + function accept(WP_REST_Request $request) { + try { + $service = new RsvTimetableReservationService(); + $service->accept($request->get_param('code')); + return new WP_REST_Response(['status' => 'accepted'], 200); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404); + } + } + + function refuse(WP_REST_Request $request) { + try { + $service = new RsvTimetableReservationService(); + $service->refuse($request->get_param('code')); + return new WP_REST_Response(['status' => 'refused'], 200); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404); + } + } +} diff --git a/includes/Events/RsvEventDispatcher.php b/includes/Events/RsvEventDispatcher.php new file mode 100644 index 0000000..e9c3238 --- /dev/null +++ b/includes/Events/RsvEventDispatcher.php @@ -0,0 +1,39 @@ +dispatch($event); + } + + public static function listen(string $event_class, callable $listener): void { + self::bus()->listen($event_class, $listener); + } +} diff --git a/includes/Events/RsvFormSubmitClosedEvent.php b/includes/Events/RsvFormSubmitClosedEvent.php new file mode 100644 index 0000000..cab8ce8 --- /dev/null +++ b/includes/Events/RsvFormSubmitClosedEvent.php @@ -0,0 +1,17 @@ +Nový požadavek o rezervaci +

Rezervace č. {{reservation_id}} čeká na vaše schválení.

+

Datum: {{date}}

+

Čas: {{start}} – {{end}}

+

+ Přijmout +  |  + Odmítnout +

+ "; + + private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata'; + private const string DEFAULT_ACCEPTED_BODY = " +

Vaše rezervace byla přijata

+

Vaše rezervace byla schválena. Těšíme se na vás!

+ "; + + private const string DEFAULT_REFUSED_SUBJECT = 'Rezervace zamítnuta'; + private const string DEFAULT_REFUSED_BODY = " +

Vaše rezervace byla zamítnuta

+

Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.

+ "; + + public static function register(): void { + RsvEventDispatcher::listen(RsvTimetableReservationPendingEvent::class, [self::class, 'on_pending']); + RsvEventDispatcher::listen(RsvFormSubmitClosedEvent::class, [self::class, 'on_form_submit_closed']); + } + + public static function on_pending(RsvTimetableReservationPendingEvent $event): void { + try { + $tz = wp_timezone(); + $start_dt = (clone $event->reservation->start_utc)->setTimezone($tz); + $end_dt = (clone $event->reservation->end_utc)->setTimezone($tz); + + $body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [ + 'reservation_id' => (string) $event->reservation_id, + 'date' => esc_html($start_dt->format('Y-m-d')), + 'start' => esc_html($start_dt->format('H:i')), + 'end' => esc_html($end_dt->format('H:i')), + 'accept_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code)), + 'refuse_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code)), + ]); + + (new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body); + } catch (\Throwable $e) { + Logger::error($e); + } + } + + /** + * HTML-escape scalar form values for safe interpolation into an HTML email + * body. Non-scalar values (e.g. nested arrays) are dropped to an empty + * string since the templater only substitutes plain placeholders. + * + * @param array $values + * @return array + */ + private static function escape_values(array $values): array { + $escaped = []; + foreach ($values as $key => $value) { + $escaped[$key] = is_scalar($value) ? esc_html((string) $value) : ''; + } + return $escaped; + } + + public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void { + try { + $form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id); + if ($form_submit === null) { + Logger::warning("on_form_submit_closed: form_submit {$event->form_submit_id} not found"); + return; + } + + $definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']); + if ($definition === null) { + Logger::warning("on_form_submit_closed: form definition for submit {$event->form_submit_id} not found"); + return; + } + + $elements = $definition['definition']['elements'] ?? []; + $reservation_element = null; + foreach ($elements as $el) { + if (($el['type'] ?? '') === 'reservation') { + $reservation_element = $el; + break; + } + } + + if ($reservation_element === null) { + return; + } + + $template_key = $event->accepted ? 'on_accepted' : 'on_refused'; + $templates = $reservation_element['email_templates'] ?? []; + $tpl = $templates[$template_key] ?? []; + + $tpl_enabled = $tpl['enabled'] ?? null; + $enabled = is_bool($tpl_enabled) ? $tpl_enabled : ($template_key === 'on_accepted'); + if (!$enabled) { + return; + } + + $email_key = $definition['definition']['email_key'] ?? null; + $form_values = $form_submit['values'] ?? []; + $user_email = is_string($email_key) && $email_key !== '' ? ($form_values[$email_key] ?? null) : null; + + if ($user_email === null || $user_email === '') { + Logger::warning("on_form_submit_closed: no user email for submit {$event->form_submit_id}"); + return; + } + + $default_subject = $event->accepted ? self::DEFAULT_ACCEPTED_SUBJECT : self::DEFAULT_REFUSED_SUBJECT; + $default_body = $event->accepted ? self::DEFAULT_ACCEPTED_BODY : self::DEFAULT_REFUSED_BODY; + + // Treat blank (admin cleared the field) the same as "use the default". + $subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject; + $body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body; + + $templater = new RsvEmailTemplater(); + + // Subject is plain text: render with raw values, then strip any tags + // or newlines to avoid header issues. + $subject = sanitize_text_field($templater->render($subject_tpl, $form_values)); + + // Body is HTML: escape the user-submitted values before interpolation + // so they can't inject markup into the message. + $body = $templater->render($body_tpl, self::escape_values($form_values)); + + (new RsvEmailSender())->send($user_email, $subject, $body); + } catch (\Throwable $e) { + Logger::error($e); + } + } +} diff --git a/includes/Listeners/RsvGoogleCalendarListener.php b/includes/Listeners/RsvGoogleCalendarListener.php new file mode 100644 index 0000000..d5bd5ef --- /dev/null +++ b/includes/Listeners/RsvGoogleCalendarListener.php @@ -0,0 +1,59 @@ +is_google_connected()) { + return; + } + + $calendar_id = self::resolve_calendar_id($gcal, $event->timetable_id); + if (!$calendar_id) { + return; + } + + $status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed'; + $gcal->add_event( + $calendar_id, + "Reservation #{$event->reservation->id}", + $event->reservation->start, + $event->reservation->end, + $event->reservation->user_email, + $event->reservation->id, + $status, + ); + } catch (\Throwable $e) { + error_log('RsvGoogleCalendarListener::on_created: ' . $e->getMessage()); + } + } + + public static function on_accepted(RsvTimetableReservationAcceptedEvent $event): void { + // Future: update the calendar event status to 'confirmed'. + } + + public static function on_refused(RsvTimetableReservationRefusedEvent $event): void { + // Future: cancel the calendar event. + } + + // ------------------------------------------------------------------------- + + private static function resolve_calendar_id(RsvGoogleCalendarService $gcal, int $timetable_id): ?string { + $timetable_service = new RsvTimetableService(); + $timetable = $timetable_service->get($timetable_id); + + if ($timetable && !empty($timetable->google_calendar_id)) { + return $timetable->google_calendar_id; + } + + $global = $gcal->get_calendar_id(); + return $global ?: null; + } +} diff --git a/includes/Models/RsvReservation.php b/includes/Models/RsvReservation.php new file mode 100644 index 0000000..cee6e71 --- /dev/null +++ b/includes/Models/RsvReservation.php @@ -0,0 +1,55 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'form_submit_id' => ['type' => 'integer', 'readonly' => true], + 'is_confirmed' => ['type' => 'boolean', 'readonly' => true], + 'timetable_reservations' => [ + 'type' => 'array', + 'required' => true, + 'items' => RsvTimetableReservation::schema(), + ], + ], + ]; + } + + public static function from_array(array $data) : self { + return new self( + intval($data['id'] ?? null), + intval($data['form_submit_id'] ?? null), + $data['is_confirmed'] ?? null, + array_map(fn($t) => + new RsvTimetableReservation(null, $data['timetable_id'], $t['start'], $t['end']), + $data['timetable_reservations'] ?? []) + ); + } + + public function __construct(?int $id, int $form_submit_id, bool|null $is_confirmed, array $timetable_reservations) + { + $this->id = $id; + $this->form_submit_id = $form_submit_id; + $this->is_confirmed = $is_confirmed; + $this->timetable_reservations = $timetable_reservations; + } + + public function to_array() { + return [ + 'id' => $this->id, + 'form_submit_id' => $this->form_submit_id, + 'is_confirmed' => $this->is_confirmed, + // 'timetable_reservations' => $this->timetable_reservations, + ]; + } +} diff --git a/includes/Models/RsvReservationType.php b/includes/Models/RsvReservationType.php new file mode 100644 index 0000000..0b06517 --- /dev/null +++ b/includes/Models/RsvReservationType.php @@ -0,0 +1,52 @@ +index = $data['index']; + $this->type = $data['type']; + $this->configuration = $data['configuration'] ?? null; + } +} + +class RsvReservationTypeConfiguration { + public array $steps = []; + + public function __construct(array $data) { + $this->steps = []; + foreach( $data['steps'] as $step ) { + array_push( $this->steps, new RsvReservationTypeConfigurationStep($step) ); + } + } +} + +class RsvReservationType { + public int $id; + + public string $name; + + public string $description; + + public $configuration; + + public function __construct(array $data) { + $this->id = $data['id']; + $this->name = $data['name']; + $this->description = $data['description']; + $this->configuration = json_decode($data['configuration'], true); + } + + public function to_array() { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'configuration' => json_encode($this->configuration) + ]; + } +} diff --git a/includes/Models/RsvTimetable.php b/includes/Models/RsvTimetable.php new file mode 100644 index 0000000..2046c8c --- /dev/null +++ b/includes/Models/RsvTimetable.php @@ -0,0 +1,30 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'name' => ['type' => 'string', 'required' => true, 'minLength' => 1], + 'block_size' => ['type' => 'integer', 'required' => true, 'minimum' => 1], + 'maintainer_email' => ['type' => ['string', 'null'], 'format' => 'email'], + 'google_calendar_id' => ['type' => ['string', 'null']], + ], + ]; + } + + public function __construct(array $data) { + $this->id = $data['id'] ?? null; + $this->name = $data['name']; + $this->block_size = $data['block_size'] ?? 0; + $this->google_calendar_id = $data['google_calendar_id'] ?? null; + $this->maintainer_email = $data['maintainer_email'] ?? null; + } +} \ No newline at end of file diff --git a/includes/Models/RsvTimetableAvailability.php b/includes/Models/RsvTimetableAvailability.php new file mode 100644 index 0000000..9f9e644 --- /dev/null +++ b/includes/Models/RsvTimetableAvailability.php @@ -0,0 +1,21 @@ + $occupancy Number of available seats for each time block + */ + public function __construct( + public int $from_minutes, + public int $to_minutes, + public int $block_size_in_minutes, + public array $occupancy + ) { } + + public function push_block(int $capacity) { + $this->occupancy[] = $capacity; + $this->to_minutes += $this->block_size_in_minutes; + } +} diff --git a/includes/Models/RsvTimetableCapacity.php b/includes/Models/RsvTimetableCapacity.php new file mode 100644 index 0000000..6fc4d67 --- /dev/null +++ b/includes/Models/RsvTimetableCapacity.php @@ -0,0 +1,95 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'timetable_id' => ['type' => 'integer', 'readonly' => true], + 'capacity' => ['type' => 'integer', 'required' => true, 'minimum' => 1], + 'min_lead_time_minutes' => ['type' => 'integer', 'required' => true, 'minimum' => 0], + 'date' => ['type' => 'string', 'required' => true, 'format' => 'date'], + 'start_time' => ['type' => 'integer', 'required' => true, 'minimum' => 0], + 'end_time' => ['type' => 'integer', 'required' => true, 'minimum' => 0], + 'repeat_period_in_days' => ['type' => 'integer', 'required' => true, 'minimum' => 0], + 'repeat_times' => ['type' => 'integer', 'required' => true, 'minimum' => 0], + 'requires_confirmation' => ['type' => 'boolean'], + ], + ]; + } + + public static function from_array(array $data): self { + return new self( + $data['id'], + $data['timetable_id'], + $data['capacity'], + $data['min_lead_time_minutes'], + new DateTime($data['date']), + $data['start_time'], + $data['end_time'], + $data['repeat_period_in_days'], + $data['repeat_times'], + $data['requires_confirmation'] ?? false + ); + } + + public function __construct( + int|null $id, + int $timetable_id, + int $capacity, + int $min_lead_time_minutes, + DateTime $date, + int $start_time, + int $end_time, + int|null $repeat_period_in_days, + int|null $repeat_times, + bool $requires_confirmation + ) { + $this->id = $id; + $this->timetable_id = $timetable_id; + $this->capacity = $capacity; + $this->min_lead_time_minutes = $min_lead_time_minutes; + $this->date = $date; + $this->start_time = $start_time; + $this->end_time = $end_time; + $this->repeat_period_in_days = $repeat_period_in_days; + $this->repeat_times = $repeat_times; + $this->requires_confirmation = $requires_confirmation; + // $this->repeat_times = $data['repeat_times']; + + // $this->requires_confirmation = $data['requires_confirmation'] ?? false; + } + + public function to_array(): array { + return [ + 'id' => $this->id, + 'timetable_id' => $this->timetable_id, + 'capacity' => $this->capacity, + 'min_lead_time_minutes' => $this->min_lead_time_minutes, + 'date' => $this->date->format('Y-m-d'), + 'start_time' => $this->start_time, + 'end_time' => $this->end_time, + 'repeat_period_in_days' => $this->repeat_period_in_days, + 'repeat_times' => $this->repeat_times, + 'requires_confirmation' => $this->requires_confirmation, + ]; + } +} diff --git a/includes/Models/RsvTimetableReservation.php b/includes/Models/RsvTimetableReservation.php new file mode 100644 index 0000000..aa29458 --- /dev/null +++ b/includes/Models/RsvTimetableReservation.php @@ -0,0 +1,46 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'timetable_id' => ['type' => 'integer', 'required' => true], + 'start_utc' => ['type' => 'string', 'required' => true, 'format' => 'date-time'], + 'end_utc' => ['type' => 'string', 'required' => true, 'format' => 'date-time'], + ], + ]; + } + + public function __construct(int|null $id, int $timetable_id, DateTime $start_utc, DateTime $end_utc) { + $this->id = $id; + $this->timetable_id = $timetable_id; + $this->start_utc = $start_utc; + $this->end_utc = $end_utc; + } + + public static function from_array(array $data): self { + $utc = new DateTimeZone('UTC'); + return new self( + $data['id'] ?? null, + (int) $data['timetable_id'], + new DateTime($data['start_utc'], $utc), + new DateTime($data['end_utc'], $utc), + ); + } + + public function to_array(): array { + return [ + 'id' => $this->id, + 'timetable_id' => $this->timetable_id, + 'start_utc' => $this->start_utc->format('Y-m-d H:i:s'), + 'end_utc' => $this->end_utc->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/includes/README.md b/includes/README.md new file mode 100644 index 0000000..135a960 --- /dev/null +++ b/includes/README.md @@ -0,0 +1,15 @@ +# PHP Code Structure + +The PHP side combines processing and drawing forms. + +## Views + +Output side of the web-interface layer to the application. + +## Services + +Application-layer classes. + +## Controllers + +API classes. diff --git a/includes/Repository/RsvFormDefinitionRepository.php b/includes/Repository/RsvFormDefinitionRepository.php new file mode 100644 index 0000000..e322958 --- /dev/null +++ b/includes/Repository/RsvFormDefinitionRepository.php @@ -0,0 +1,65 @@ +table = Db::prefix() . 'rsv_form_definition'; + } + + public function add(string $name, array $definition): int { + return Db::insert($this->table, [ + 'name' => $name, + 'definition' => json_encode($definition), + ]); + } + + public function get_all(?int $limit = null, int $skip = 0): array { + if ($limit === null) { + return Db::get_results( + "SELECT form_id, name FROM {$this->table} ORDER BY form_id ASC", + [], + ARRAY_A + ); + } + return Db::get_results( + "SELECT form_id, name FROM {$this->table} ORDER BY form_id ASC LIMIT %d OFFSET %d", + [$limit, $skip], + ARRAY_A + ); + } + + public function count_all(): int { + return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}"); + } + + public function update(int $id, string $name, array $definition): void { + Db::update( + $this->table, + ['name' => $name, 'definition' => json_encode($definition)], + ['form_id' => $id] + ); + } + + public function delete(int $id): void { + Db::delete($this->table, ['form_id' => $id]); + } + + public function get(int $id): ?array { + $row = Db::get_row( + "SELECT * FROM {$this->table} WHERE form_id = %d", + [$id], + ARRAY_A + ); + + if ($row === null) { + return null; + } + + $row['definition'] = json_decode($row['definition'], true); + return $row; + } +} diff --git a/includes/Repository/RsvFormSubmitRepository.php b/includes/Repository/RsvFormSubmitRepository.php new file mode 100644 index 0000000..97320cb --- /dev/null +++ b/includes/Repository/RsvFormSubmitRepository.php @@ -0,0 +1,37 @@ +table = Db::prefix() . 'rsv_form_submit'; + } + + public function add(int $form_id, array $values): int { + return Db::insert($this->table, [ + 'form_id' => $form_id, + 'values' => json_encode($values), + ]); + } + + public function delete(int $id): void { + Db::delete($this->table, ['form_submit_id' => $id]); + } + + public function get(int $id): ?array { + $row = Db::get_row( + "SELECT * FROM {$this->table} WHERE form_submit_id = %d", + [$id], + ARRAY_A + ); + + if ($row === null) { + return null; + } + + $row['values'] = json_decode($row['values'], true); + return $row; + } +} diff --git a/includes/Repository/RsvReservationRepository.php b/includes/Repository/RsvReservationRepository.php new file mode 100644 index 0000000..4f84f30 --- /dev/null +++ b/includes/Repository/RsvReservationRepository.php @@ -0,0 +1,125 @@ +table = Db::prefix() . 'rsv_reservation'; + $this->timetable_reservations_table = Db::prefix() . 'rsv_timetable_reservation'; + } + + public function get_all(?int $limit = null, int $skip = 0) { + if ($limit === null) { + return Db::get_results("SELECT * FROM {$this->table} ORDER BY id DESC"); + } + return Db::get_results( + "SELECT * FROM {$this->table} ORDER BY id DESC LIMIT %d OFFSET %d", + [$limit, $skip] + ); + } + + public function count_all(): int { + return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}"); + } + + public function get(int $id) { + return Db::get_row( + "SELECT * FROM {$this->table} WHERE id = %d", + [$id] + ); + } + + public function get_detail(int $id): ?array { + $reservation = Db::get_row( + "SELECT * FROM {$this->table} WHERE id = %d", + [$id], + ARRAY_A + ); + + if ($reservation === null) { + return null; + } + + $form_submit_table = Db::prefix() . 'rsv_form_submit'; + $form_submit = Db::get_row( + "SELECT `values` FROM {$form_submit_table} WHERE form_submit_id = %d", + [(int) $reservation['form_submit_id']], + ARRAY_A + ); + $reservation['form_values'] = $form_submit ? json_decode($form_submit['values'], true) : null; + + $reservation['timetable_reservations'] = Db::get_results( + "SELECT id, timetable_id, `start_utc`, `end_utc` FROM {$this->timetable_reservations_table} WHERE reservation_id = %d", + [$id], + ARRAY_A + ); + + $confirmation_table = Db::prefix() . 'rsv_timetable_reservation_confirmation'; + $reservation['pending_confirmation'] = (int) Db::get_var( + "SELECT COUNT(*) FROM {$confirmation_table} c + JOIN {$this->timetable_reservations_table} tr ON tr.id = c.timetable_reservation_id + WHERE tr.reservation_id = %d", + [$id] + ) > 0; + + return $reservation; + } + + public function insert(array $data): int { + return Db::insert($this->table, $data); + } + + public function delete(int $id): void { + Db::delete($this->table, ['id' => $id]); + } + + public function count_subitems(int $reservation_id): int { + return (int) Db::get_var( + "SELECT COUNT(*) FROM {$this->timetable_reservations_table} WHERE reservation_id = %d", + [$reservation_id] + ); + } + + public function are_subitems_confirmed(int $reservation_id): bool { + $result = Db::get_row( + "SELECT MIN(rtr.is_confirmed) AS is_confirmed + FROM {$this->table} AS rr + LEFT JOIN {$this->timetable_reservations_table} AS rtr ON rr.id = rtr.reservation_id + WHERE rr.id = %d", + [$reservation_id] + ); + return $result !== null && $result->is_confirmed == 1; + } + + /** + * Whether the reservation has reached a terminal state. `is_confirmed` is + * NULL while pending, 1 once confirmed and 0 once refused, so any non-NULL + * value means the outcome is settled. + */ + public function is_resolved(int $reservation_id): bool { + $result = Db::get_row( + "SELECT is_confirmed FROM {$this->table} WHERE id = %d", + [$reservation_id] + ); + return $result !== null && $result->is_confirmed !== null && $result->is_confirmed; + } + + public function set_resolved_state(int $reservation_id, bool $confirmed): void { + Db::update( + $this->table, + ['is_confirmed' => $confirmed ? 1 : 0], + ['id' => $reservation_id] + ); + } + + public function get_form_submit_id(int $reservation_id): ?int { + $result = Db::get_var( + "SELECT form_submit_id FROM {$this->table} WHERE id = %d", + [$reservation_id] + ); + return $result !== null ? (int) $result : null; + } +} diff --git a/includes/Repository/RsvTimetableCapacityRepository.php b/includes/Repository/RsvTimetableCapacityRepository.php new file mode 100644 index 0000000..82b2a73 --- /dev/null +++ b/includes/Repository/RsvTimetableCapacityRepository.php @@ -0,0 +1,104 @@ +table = Db::prefix() . 'rsv_timetable_capacity'; + } + + public function get_all($timetable_id, ?int $limit = null, int $skip = 0): array { + if ($limit === null) { + return Db::get_results( + "SELECT * FROM {$this->table} WHERE timetable_id = %d ORDER BY id", + [$timetable_id] + ); + } + return Db::get_results( + "SELECT * FROM {$this->table} WHERE timetable_id = %d ORDER BY id LIMIT %d OFFSET %d", + [$timetable_id, $limit, $skip] + ); + } + + public function count_all($timetable_id): int { + return (int) Db::get_var( + "SELECT COUNT(*) FROM {$this->table} WHERE timetable_id = %d", + [$timetable_id] + ); + } + + public function get(int $id): ?RsvTimetableCapacity { + $row = Db::get_row( + "SELECT * FROM {$this->table} WHERE id = %d", + [$id], + ARRAY_A + ); + + return $row === null ? null : RsvTimetableCapacity::from_array($row); + } + + public function create(RsvTimetableCapacity $capacity): int { + return Db::insert($this->table, $capacity->to_array()); + } + + public function delete(int $id): void { + Db::delete($this->table, ['id' => $id]); + } + + public function update(int $id, RsvTimetableCapacity $capacity): int { + $capacity->id = $id; + return Db::update($this->table, $capacity->to_array(), ['id' => $id]); + } + + public function get_overlapping_capacity(int $timetable_id, DateTime $start, DateTime $end): array { + $start_str = (clone $start)->setTimezone(wp_timezone())->format('Y-m-d H:i:s'); + $end_str = (clone $end)->setTimezone(wp_timezone())->format('Y-m-d H:i:s'); + + return Db::get_results( + "SELECT * FROM {$this->table} + WHERE timetable_id = %d + AND ( + date = DATE(%s) + OR ( + repeat_period_in_days > 0 + AND DATEDIFF(DATE(%s), date) >= 0 + AND MOD(DATEDIFF(DATE(%s), date), repeat_period_in_days) = 0 + ) + ) + AND DATE_ADD(DATE(%s), INTERVAL start_time MINUTE) < %s + AND DATE_ADD(DATE(%s), INTERVAL end_time MINUTE) > %s + ORDER BY start_time", + [$timetable_id, $start_str, $start_str, $start_str, $start_str, $end_str, $start_str, $start_str] + ); + } + + public function get_capacities_for_date(int $timetable_id, DateTime $date) : array { + $row = Db::get_results( + "SELECT * + FROM {$this->table} + WHERE timetable_id = %d + AND (date = DATE(%s) OR (repeat_period_in_days > 0 AND MOD(DATEDIFF(date, %s), repeat_period_in_days) = 0)) + ORDER BY start_time ASC", + [$timetable_id, $date->format('Y-m-d'), $date->format('Y-m-d')], + ARRAY_A + ); + + return array_map(fn($x) => RsvTimetableCapacity::from_array($x), $row); + } + + public function get_available_range_for_date(int $timetable_id, DateTime $date) { + if ($date === null) { + throw new InvalidArgumentException('Invalid date'); + } + + return Db::get_row( + "SELECT MAX(start_time) AS `from`, MIN(end_time) AS `to` + FROM {$this->table} + WHERE timetable_id = %d + AND (date = DATE(%s) OR (repeat_period_in_days > 0 AND MOD(DATEDIFF(date, %s), repeat_period_in_days) = 0))", + [$timetable_id, $date->format('Y-m-d'), $date->format('Y-m-d')] + ); + } +} diff --git a/includes/Repository/RsvTimetableRepository.php b/includes/Repository/RsvTimetableRepository.php new file mode 100644 index 0000000..9f72ea7 --- /dev/null +++ b/includes/Repository/RsvTimetableRepository.php @@ -0,0 +1,83 @@ +table = Db::prefix() . 'rsv_timetable'; + } + + public function get_all(?int $limit = null, int $skip = 0): array { + if ($limit === null) { + return Db::get_results("SELECT * FROM {$this->table} ORDER BY id"); + } + return Db::get_results( + "SELECT * FROM {$this->table} ORDER BY id LIMIT %d OFFSET %d", + [$limit, $skip] + ); + } + + public function count_all(): int { + return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}"); + } + + public function get(int $id): ?RsvTimetable { + $row = Db::get_row( + "SELECT * FROM {$this->table} WHERE id = %d", + [$id], + ARRAY_A + ); + if ($row === null) { + return null; + } + return new RsvTimetable($row); + } + + public function create(RsvTimetable $timetable): int { + return Db::insert($this->table, [ + 'name' => $timetable->name, + 'block_size' => $timetable->block_size, + 'maintainer_email' => $timetable->maintainer_email, + ]); + } + + public function update(int $id, RsvTimetable $timetable): int { + return Db::update( + $this->table, + [ + 'name' => $timetable->name, + 'block_size' => $timetable->block_size, + 'maintainer_email' => $timetable->maintainer_email, + ], + ['id' => $id] + ); + } + + public function get_all_maintainer_emails(): array { + return Db::get_col( + "SELECT DISTINCT maintainer_email FROM {$this->table} + WHERE maintainer_email IS NOT NULL AND maintainer_email != ''" + ); + } + + public function get_maintainer_email(int $id): ?string { + return Db::get_var( + "SELECT maintainer_email FROM {$this->table} WHERE id = %d", + [$id] + ) ?: null; + } + + public function set_google_calendar_id(int $id, ?string $calendar_id): void { + Db::update( + $this->table, + ['google_calendar_id' => $calendar_id], + ['id' => $id] + ); + } + + public function delete(int $id): int { + return Db::delete($this->table, ['id' => $id]); + } +} diff --git a/includes/Repository/RsvTimetableReservationRepository.php b/includes/Repository/RsvTimetableReservationRepository.php new file mode 100644 index 0000000..d9987b9 --- /dev/null +++ b/includes/Repository/RsvTimetableReservationRepository.php @@ -0,0 +1,129 @@ +table = Db::prefix() . 'rsv_timetable_reservation'; + $this->confirmation_table = Db::prefix() . 'rsv_timetable_reservation_confirmation'; + } + + public function get_all(): array { + return array_map( + 'RsvTimetableReservation::from_array', + Db::get_results("SELECT * FROM {$this->table}") + ); + } + + public function get(int $id): RsvTimetableReservation { + return RsvTimetableReservation::from_array(Db::get_row( + "SELECT * FROM {$this->table} WHERE id = %d", + [$id], + ARRAY_A + )); + } + + public function get_overlapping(int $timetable_id, DateTime $start_utc, DateTime $end_utc): array { + return array_map( + 'RsvTimetableReservation::from_array', + Db::get_results( + "SELECT * FROM {$this->table} + WHERE timetable_id = %d + AND `start_utc` < %s + AND `end_utc` > %s", + [$timetable_id, $end_utc, $start_utc] + ) + ); + } + + public function insert(array $data): int { + return Db::insert($this->table, $data); + } + + public function insert_confirmation(int $reservation_id, int $timetable_reservation_id, string $code): void { + Db::insert($this->confirmation_table, [ + 'reservation_id' => $reservation_id, + 'timetable_reservation_id' => $timetable_reservation_id, + 'code' => $code, + ]); + } + + public function get_confirmation(string $code): ?array { + return Db::get_row( + "SELECT c.* + FROM {$this->confirmation_table} c + JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id + WHERE c.code = %s + LIMIT 1", + [$code], + ARRAY_A + ); + } + + public function set_confirmed(int $timetable_reservation_id, bool $state): void { + Db::update( + $this->table, + ['is_confirmed' => $state ? 1 : 0], + ['id' => $timetable_reservation_id] + ); + } + + public function delete_confirmation(string $code): void { + Db::delete($this->confirmation_table, ['code' => $code]); + } + + public function has_pending_confirmation(int $reservation_id): bool { + return (int) Db::get_var( + "SELECT COUNT(*) FROM {$this->confirmation_table} c + JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id + WHERE tr.reservation_id = %d", + [$reservation_id] + ) > 0; + } + + public function get_confirmation_code(int $reservation_id): ?string { + return Db::get_var( + "SELECT c.code FROM {$this->confirmation_table} c + JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id + WHERE tr.reservation_id = %d + LIMIT 1", + [$reservation_id] + ); + } + + public function get_by_timetable(int $timetable_id, ?int $limit = null, int $skip = 0): array { + $sql = "SELECT tr.*, c.timetable_reservation_id AS pending_confirmation_id + FROM {$this->table} tr + LEFT JOIN {$this->confirmation_table} c ON c.timetable_reservation_id = tr.id + WHERE tr.timetable_id = %d + ORDER BY tr.start_utc DESC"; + + if ($limit === null) { + return Db::get_results($sql, [$timetable_id], ARRAY_A); + } + return Db::get_results($sql . " LIMIT %d OFFSET %d", [$timetable_id, $limit, $skip], ARRAY_A); + } + + public function count_by_timetable(int $timetable_id): int { + return (int) Db::get_var( + "SELECT COUNT(*) FROM {$this->table} WHERE timetable_id = %d", + [$timetable_id] + ); + } + + public function get_reservations_on_date(int $timetable_id, DateTime $date_utc): array { + return array_map( + 'RsvTimetableReservation::from_array', + Db::get_results( + "SELECT * FROM {$this->table} + WHERE timetable_id = %d AND DATE(`start_utc`) = DATE(%s) + ORDER BY `start_utc`", + [$timetable_id, $date_utc->format('Y-m-d')], + ARRAY_A + ) + ); + } +} diff --git a/includes/RsvAdminMenuDefinition.php b/includes/RsvAdminMenuDefinition.php new file mode 100644 index 0000000..bffad51 --- /dev/null +++ b/includes/RsvAdminMenuDefinition.php @@ -0,0 +1,43 @@ + rest_url('reservations/v1'), + 'nonce' => wp_create_nonce('wp_rest'), + ]); + + wp_localize_script('rsv_api', 'ReservairStrings', [ + 'timeline' => [ + 'not_reservable' => 'Tento objekt nelze rezervovat.', + 'no_blocks' => 'Tento den není dostupný žádný blok. Vyberte jiné datum.', + 'seats' => 'míst', + ], + 'summary' => [ + 'title' => 'Vybrané termíny', + 'clear_all' => 'Smazat vše', + 'count_one' => '1 termín', + 'count_few' => '%d termíny', + 'count_many' => '%d termínů', + 'currency' => 'Kč', + ], + 'form' => [ + 'success_title' => 'Rezervace potvrzena!', + 'success_subtitle' => 'Potvrzení jsme zaslali na váš e‑mail.', + 'new_reservation' => 'Nová rezervace', + 'error_generic' => 'Něco se pokazilo. Zkuste to prosím znovu.', + ], + ]); + + rsv_css('reservations-styles', 'css/RsvMainStyle.css'); + rsv_css('rsv-form-summary-styles', 'css/components/RsvFormSummaryStyles.css'); + rsv_css('rsv-calendar-styles', 'css/components/RsvCalendarStyles.css'); + rsv_css('rsv-form-styles', 'css/components/RsvFormStyles.css'); + rsv_css('rsv-time-slot-styles', 'css/components/RsvTimeSlotsStyles.css'); +} + +// --- Public hooks --- + +function rsv_enqueue_assets(): void { + rsv_enqueue_shared_assets(); + + rsv_js('rsv_timetable_service', 'js/services/RsvTimetableService.js'); + rsv_js('rsv_form_sender', 'js/forms/RsvFormSender.js'); + rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js'); +} + +function rsv_enqueue_admin_assets(): void { + rsv_enqueue_shared_assets(); + + $admin_js = plugin_dir_path(__FILE__) . '../src/components/admin.js'; + wp_enqueue_script('admin', plugin_dir_url(__FILE__) . '../src/components/admin.js', [], filemtime($admin_js)); + + rsv_js('rsv_inline_form_builder', 'js/forms/RsvInlineFormBuilder.js'); + rsv_js('datagrid', 'js/elements/RsvDatagrid.js'); + rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js'); + // RsvAdminForm needs the encoder, the localized nonce (rsv_api), and + // show_notice() (admin) — declare them as deps so load order is correct. + rsv_js('rsv_admin_form', 'js/forms/RsvAdminForm.js', ['rsv_form_encoder', 'rsv_api', 'admin']); + rsv_css('rsv-admin-style', 'css/RsvAdminStyle.css'); +} diff --git a/includes/RsvCapabilities.php b/includes/RsvCapabilities.php new file mode 100644 index 0000000..f447af3 --- /dev/null +++ b/includes/RsvCapabilities.php @@ -0,0 +1,60 @@ +has_cap( self::MANAGE ) ) { + $role->add_cap( self::MANAGE ); + } + } + + update_option( self::VERSION_OPTION, self::VERSION ); + } + + /** Remove the capability from every role and clear the version marker. */ + public static function revoke(): void { + foreach ( array_keys( wp_roles()->roles ) as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + $role->remove_cap( self::MANAGE ); + } + } + + delete_option( self::VERSION_OPTION ); + } +} diff --git a/includes/RsvCrypto.php b/includes/RsvCrypto.php new file mode 100644 index 0000000..f3ade48 --- /dev/null +++ b/includes/RsvCrypto.php @@ -0,0 +1,49 @@ +get_charset_collate(); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_form_definition ( + form_id bigint unsigned NOT NULL AUTO_INCREMENT, + name TINYTEXT NOT NULL, + definition JSON NOT NULL, + PRIMARY KEY (form_id) + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_form_submit ( + form_submit_id bigint unsigned NOT NULL AUTO_INCREMENT, + form_id bigint unsigned NOT NULL, + submitted_on_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `values` JSON NOT NULL, + PRIMARY KEY (form_submit_id), + CONSTRAINT fk_form_submit_definition + FOREIGN KEY (form_id) REFERENCES {$wpdb->prefix}rsv_form_definition (form_id) + ON DELETE CASCADE + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + name TINYTEXT NOT NULL, + block_size int unsigned NOT NULL DEFAULT 0, + maintainer_email TINYTEXT NULL DEFAULT NULL, + google_calendar_id TINYTEXT NULL DEFAULT NULL, + PRIMARY KEY (id) + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_reservation ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + form_submit_id bigint unsigned NOT NULL, + is_confirmed tinyint(1) NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_reservation_form_submit + FOREIGN KEY (form_submit_id) REFERENCES {$wpdb->prefix}rsv_form_submit (form_submit_id) + ON DELETE CASCADE + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_capacity ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + timetable_id bigint unsigned NOT NULL, + capacity int unsigned NOT NULL DEFAULT 1, + min_lead_time_minutes int unsigned NOT NULL DEFAULT 0, + date DATE NOT NULL, + start_time smallint unsigned NOT NULL, + end_time smallint unsigned NOT NULL, + repeat_period_in_days int unsigned NOT NULL DEFAULT 0, + repeat_times int unsigned NOT NULL DEFAULT 0, + requires_confirmation tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_cap_timetable_date (timetable_id, date), + CONSTRAINT fk_capacity_timetable + FOREIGN KEY (timetable_id) REFERENCES {$wpdb->prefix}rsv_timetable (id) + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_reservation ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + timetable_id bigint unsigned NOT NULL, + reservation_id bigint unsigned NOT NULL, + start_utc DATETIME NOT NULL, + end_utc DATETIME NOT NULL, + is_confirmed tinyint(1) NULL DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_ttr_timetable_time (timetable_id, start_utc, end_utc), + CONSTRAINT fk_timetable_reservation_timetable + FOREIGN KEY (timetable_id) REFERENCES {$wpdb->prefix}rsv_timetable (id), + CONSTRAINT fk_timetable_reservation_reservation + FOREIGN KEY (reservation_id) REFERENCES {$wpdb->prefix}rsv_reservation (id) + ON DELETE CASCADE + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_reservation_confirmation ( + reservation_id bigint unsigned NOT NULL, + timetable_reservation_id bigint unsigned NOT NULL, + code VARCHAR(32) NOT NULL, + PRIMARY KEY (timetable_reservation_id), + CONSTRAINT fk_trc_timetable_reservation + FOREIGN KEY (timetable_reservation_id) REFERENCES {$wpdb->prefix}rsv_timetable_reservation (id) + ON DELETE CASCADE, + CONSTRAINT fk_trc_reservation + FOREIGN KEY (reservation_id) REFERENCES {$wpdb->prefix}rsv_reservation (id) + ON DELETE CASCADE + ) $charset_collate;"); + + // Grant the custom capability that gates the admin REST endpoints. + RsvCapabilities::ensure(); + } + + private static function run(string $sql) : void { + if (Db::query($sql) === false) { + Logger::error('RsvInstaller error'); + } + } + + public static function uninstall() : void { + RsvCapabilities::revoke(); + } +} diff --git a/includes/RsvRestApiDefinition.php b/includes/RsvRestApiDefinition.php new file mode 100644 index 0000000..bb94ff9 --- /dev/null +++ b/includes/RsvRestApiDefinition.php @@ -0,0 +1,87 @@ + 'A database error occurred.'], 500); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => $e->getMessage()], 400); + } catch (\Throwable $e) { + Logger::error($e); + return new WP_REST_Response(['error' => 'An unexpected error occurred.'], 500); + } + }, 10, 4); + + // ------------------------------------------------------------------------- + // Routes + // ------------------------------------------------------------------------- + + register_rest_route('reservations/v1', '/google-callback', [ + 'methods' => 'GET', + 'callback' => 'rsv_google_oauth_callback', + // Public: OAuth redirect from Google; the handler validates the returned code. + 'permission_callback' => [RsvRestPolicy::class, 'open'], + ]); + + register_rest_route('reservations/v1', '/google-calendar-hook', [ + 'methods' => 'POST', + 'callback' => 'rsv_google_calendar_webhook', + // Public webhook: authorised by matching the secret channel id carried in + // the request headers (validated inside rsv_google_calendar_webhook). + 'permission_callback' => [RsvRestPolicy::class, 'open'], + ]); + + register_rest_route('reservations/v1', '/google-calendars', [ + 'methods' => 'GET', + 'callback' => function () { + $service = new RsvGoogleCalendarService(); + if (!$service->is_google_connected()) { + return new WP_REST_Response(['error' => 'Not connected to Google Calendar'], 403); + } + return new WP_REST_Response($service->list_calendars(), 200); + }, + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + + register_rest_route('reservations/v1', '/timetable/(?P\d+)/google-calendar', [ + 'methods' => 'PUT', + 'callback' => function (WP_REST_Request $request) { + $id = (int) $request->get_param('id'); + $calendar_id = $request->get_json_params()['calendar_id'] ?? null; + + $service = new RsvTimetableService(); + if (!$service->get($id)) { + return new WP_REST_Response(['error' => 'Timetable not found'], 404); + } + + $service->set_google_calendar_id($id, $calendar_id ?: null); + return new WP_REST_Response(['ok' => true], 200); + }, + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + + (new RsvReservationController())->register_routes(); + (new RsvTimetableDefinitionController())->register_routes(); + (new RsvTimetableAvailabilityController())->register_routes(); + (new RsvTimetableCapacityController())->register_routes(); + (new RsvTimetableReservationController())->register_routes(); + (new RsvFormController())->register_routes(); + (new RsvFormDefinitionController())->register_routes(); +} diff --git a/includes/Services/Emails/RsvEmailSender.php b/includes/Services/Emails/RsvEmailSender.php new file mode 100644 index 0000000..67cc0cb --- /dev/null +++ b/includes/Services/Emails/RsvEmailSender.php @@ -0,0 +1,22 @@ +', + 'Content-Type: text/html; charset=UTF-8', + ]; + } + + private function do_send(string $to, string $subject, string $body): void { + $success = wp_mail($to, $subject, $body, $this->headers()); + if (!$success) { + throw new \RuntimeException('wp_mail failed for ' . $to); + } + } + + public function send(string $to, string $subject, string $body): void { + $this->do_send($to, $subject, $body); + } +} diff --git a/includes/Services/Emails/RsvEmailTemplater.php b/includes/Services/Emails/RsvEmailTemplater.php new file mode 100644 index 0000000..13d1c17 --- /dev/null +++ b/includes/Services/Emails/RsvEmailTemplater.php @@ -0,0 +1,9 @@ + +
+ +
+ $data + */ + function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : bool; + + /** + * Undo a successful submit(); a no-op for elements without side effects. + * + * @param array $data + */ + function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : void; +} diff --git a/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php b/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php new file mode 100644 index 0000000..42b35f9 --- /dev/null +++ b/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php @@ -0,0 +1,87 @@ +modify("+{$block_size_minutes} minutes"); + } + + public function draw(RsvFormElementDefinition $element): void { + $timetable_id = (int) $element->getAttr('timetable_id'); + $price_per_block = (float) $element->getAttr('price_per_block', 0); + $name = $element->getName(); + ?> +
+ + +
+ getName(); + $payload = $data[$name] ?? null; + + if ($def->isRequired() && is_null($payload)) { + $result->addError($name, 'required', 'Reservation payload is required'); + return false; + } + + if (!is_array($payload) || !array_key_exists('timetable_reservations', $payload)) { + $result->addError($name, 'invalid', 'Reservation payload must be an object with timetable_reservations'); + return false; + } + + $timetable_id = intval($payload['timetable_id'] ?? null); + $timetable = (new RsvTimetableService())->get($timetable_id); + + if (!$timetable) { + $result->addError($name, 'invalid', 'Invalid timetable ID'); + return false; + } + + $reservation = new RsvReservation( + 0, + $submit_id, + false, + array_map(fn($t) => new RsvTimetableReservation( + 0, + $timetable_id, + new DateTime($t), + $this->end_from_start(new DateTime($t), $timetable->block_size) + ), $payload['timetable_reservations']) + ); + + try { + $reservation_id = (new RsvReservationService())->create($reservation); + } catch (\Throwable $e) { + // The slot may have been taken since the user selected it. + Logger::error($e); + $result->addError($name, 'creation_failed', 'Could not create the reservation. The slot may no longer be available.'); + return false; + } + + $result->setValue($name . '_reservation_id', $reservation_id); + + $price_per_block = (float) $def->getAttr('price_per_block', 0); + $result->setValue($name . '_price', $price_per_block * count($payload['timetable_reservations'])); + + return true; + } + + /** Delete the reservation created by submit(). */ + public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void { + $reservation_id = $result->getValue($def->getName() . '_reservation_id'); + if ($reservation_id) { + (new RsvReservationService())->delete((int) $reservation_id); + } + } +} diff --git a/includes/Services/Forms/Handlers/RsvReservationSummaryElementHandler.php b/includes/Services/Forms/Handlers/RsvReservationSummaryElementHandler.php new file mode 100644 index 0000000..4e12c8a --- /dev/null +++ b/includes/Services/Forms/Handlers/RsvReservationSummaryElementHandler.php @@ -0,0 +1,18 @@ + + + */ + private function rules(): array { + return [ + 'email' => ['is_email', 'Please enter a valid email address.'], + 'phone' => [$this->regexPredicate('\+?[0-9 ()\-]{6,20}'), 'Please enter a valid phone number.'], + 'digits' => [$this->regexPredicate('[0-9]+'), 'Please enter digits only.'], + ]; + } + + private function regexPredicate(string $pattern): callable { + return fn($value): bool => (bool) @preg_match('~^(?:' . $pattern . ')$~u', (string) $value); + } + + /** @return array{0:string,1:callable,2:string}|null */ + private function resolveRule(RsvFormElementDefinition $def): ?array { + $name = $def->getAttr('validation', ''); + if ($name === '') { + return null; + } + if ($name === 'pattern') { + $pattern = $def->getAttr('pattern', ''); + if ($pattern === '') { + return null; + } + return ['pattern', $this->regexPredicate($pattern), $def->getAttr('pattern_message', '') ?: 'Invalid format.']; + } + $rules = $this->rules(); + if (isset($rules[$name])) { + return [$name, $rules[$name][0], $rules[$name][1]]; + } + return null; + } + + public function draw(RsvFormElementDefinition $def): void { + $validation = $def->getAttr('validation', ''); + $type = match ($validation) { + 'email' => 'email', + 'phone' => 'tel', + 'digits' => 'number', + default => 'text', + }; + $pattern = $validation === 'pattern' ? $def->getAttr('pattern', '') : ''; + ?> +
+ + isRequired() ? "required" : "" ?>/> + getDesc() ?> +
+ getName(); + $value = $data[$name] ?? null; + + if ($def->isRequired() && (is_null($value) || $value === "")) { + $result->addError($name, 'required', 'Field is required'); + return false; + } + + $rule = $this->resolveRule($def); + if ($rule !== null && !is_null($value) && $value !== "") { + [$code, $predicate, $message] = $rule; + if (!$predicate($value)) { + $result->addError($name, $code, $message); + return false; + } + } + + $result->setValue($name, sanitize_text_field($value)); + return true; + } + + public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void { + // No side effects to undo. + } +} diff --git a/includes/Services/Forms/RsvFormData.php b/includes/Services/Forms/RsvFormData.php new file mode 100644 index 0000000..4b9b70d --- /dev/null +++ b/includes/Services/Forms/RsvFormData.php @@ -0,0 +1,21 @@ + $data + */ + public function __construct(array $data) { + // Expecting raw post values as associative array + $this->elements = $data['values'] ?? $data; + } + + public function getElements(): array { + return $this->elements; + } + + public function getValue(string $name, $default = null) { + return $this->elements[$name] ?? $default; + } +} diff --git a/includes/Services/Forms/RsvFormDefinition.php b/includes/Services/Forms/RsvFormDefinition.php new file mode 100644 index 0000000..2a752d5 --- /dev/null +++ b/includes/Services/Forms/RsvFormDefinition.php @@ -0,0 +1,45 @@ + $definition Full definition array including 'elements' and 'email_key'. + */ + public function __construct(string $id, array $definition) { + $this->_elements = []; + + if (array_key_exists('elements', $definition)) { + foreach ($definition['elements'] as $element) { + array_push($this->_elements, RsvFormElementDefinition::fromArray($element)); + } + } + + $this->_id = $id; + $this->email_key = $definition['email_key'] ?? ''; + } + + public function getId(): string { + return $this->_id; + } + + public function getEmailKey(): string { + return $this->email_key; + } + + + public function hasElements() : bool { + return count($this->_elements) > 0; + } + + /** + * @return array + */ + public function getElements() : array { + return $this->_elements; + } +} diff --git a/includes/Services/Forms/RsvFormElementDefinition.php b/includes/Services/Forms/RsvFormElementDefinition.php new file mode 100644 index 0000000..e6268cb --- /dev/null +++ b/includes/Services/Forms/RsvFormElementDefinition.php @@ -0,0 +1,65 @@ +type; + } + + public function getName(): string { + return $this->name; + } + + public function getLabel(): string { + return $this->label; + } + + public function getDesc(): string { + return $this->desc; + } + + public function isRequired(): bool { + return $this->required; + } + + public function getAttr(string $key, $default = null) { + return $this->attrs[$key] ?? $default; + } + + /** + * @param array $array Array for initializing the form element definition + * @return RsvFormElementDefinition Definition of the form element for rendering and handling. + */ + public static function fromArray(array $array): RsvFormElementDefinition { + $known = ['type','name','label','desc','required']; + $attrs = array_diff_key($array, array_flip($known)); + + $def = new self( + $array['type'], + $array['name'], + $array['label'], + $array['desc'] ?? "", + $array['required'] ?? false + ); + + $def->attrs = $attrs; + return $def; + } + + public function __construct(string $type, string $name, string $label, string $desc = "", bool $required = false) { + $this->type = $type; + $this->name = $name; + $this->label = $label; + $this->desc = $desc; + $this->required = $required; + } +} diff --git a/includes/Services/Forms/RsvFormElementRegistry.php b/includes/Services/Forms/RsvFormElementRegistry.php new file mode 100644 index 0000000..b1d4dfa --- /dev/null +++ b/includes/Services/Forms/RsvFormElementRegistry.php @@ -0,0 +1,15 @@ + */ + public array $handlers = []; + + public function register(string $type, RsvFormElementHandler $handler): void { + $this->handlers[$type] = $handler; + } + + public function get(string $type): ?RsvFormElementHandler { + return $this->handlers[$type] ?? null; + } +} diff --git a/includes/Services/Forms/RsvFormHtmlRenderer.php b/includes/Services/Forms/RsvFormHtmlRenderer.php new file mode 100644 index 0000000..f176ee7 --- /dev/null +++ b/includes/Services/Forms/RsvFormHtmlRenderer.php @@ -0,0 +1,38 @@ +hasElements()) { + return false; + } + + $form_id = esc_attr($form->getId()); + ?> +
+ + + getElements() as $element): ?> + draw_element($element); ?> + + + +
+ get($data->getType()); + if ($handler === null) { + return; + } + $handler->draw($data); + } +} diff --git a/includes/Services/Forms/RsvFormProcessor.php b/includes/Services/Forms/RsvFormProcessor.php new file mode 100644 index 0000000..53276be --- /dev/null +++ b/includes/Services/Forms/RsvFormProcessor.php @@ -0,0 +1,44 @@ +getElements() as $element) { + $handler = $rsv_form_registry->get($element->getType()); + if ($handler === null) { + $result->addError($element->getName(), 'handler_missing', 'No handler registered for element type ' . $element->getType()); + $this->rollback_all($committed, $submit_id, $data, $result); + return $result; + } + + if (!$handler->submit($element, $submit_id, $data->getElements(), $result)) { + $this->rollback_all($committed, $submit_id, $data, $result); + return $result; + } + + $committed[] = [$handler, $element]; + } + + return $result; + } + + /** + * @param array $committed + */ + private function rollback_all(array $committed, int $submit_id, RsvFormData $data, RsvFormSubmitResult $result): void { + foreach (array_reverse($committed) as [$handler, $element]) { + try { + $handler->rollback($element, $submit_id, $data->getElements(), $result); + } catch (\Throwable $e) { + Logger::error($e); + } + } + } +} diff --git a/includes/Services/Forms/RsvFormSubmission.php b/includes/Services/Forms/RsvFormSubmission.php new file mode 100644 index 0000000..3d28d0a --- /dev/null +++ b/includes/Services/Forms/RsvFormSubmission.php @@ -0,0 +1,49 @@ +get((int) $formId); + + if ($row === null) { + return ['success' => false, 'errors' => [['element' => '', 'code' => 'not_found', 'message' => 'Form not found']]]; + } + + $definition = new RsvFormDefinition($formId, $row['definition']); + $form_data = new RsvFormData($data); + $processor = new RsvFormProcessor(); + + // Persist the submission first so element handlers can link to it. + $submit_repo = new RsvFormSubmitRepository(); + $submit_id = $submit_repo->add((int) $definition->getId(), $data); + + try { + $result = $processor->submit($definition, $submit_id, $form_data); + } catch (\Throwable $e) { + Logger::error($e); + $this->discard_submission($submit_repo, $submit_id); + return ['success' => false, 'errors' => [['element' => '', 'code' => 'internal_error', 'message' => 'An unexpected error occurred.']]]; + } + + if ($result->hasErrors()) { + $this->discard_submission($submit_repo, $submit_id); + return ['success' => false, 'errors' => $result->getErrors()]; + } + + return ['success' => true, 'submit_id' => $submit_id, 'values' => $result->getValues()]; + } + + /** Remove a submission whose run failed. */ + private function discard_submission(RsvFormSubmitRepository $repo, int $submit_id): void { + try { + $repo->delete($submit_id); + } catch (\Throwable $e) { + Logger::error($e); + } + } +} diff --git a/includes/Services/Forms/RsvFormSubmitResult.php b/includes/Services/Forms/RsvFormSubmitResult.php new file mode 100644 index 0000000..ca74906 --- /dev/null +++ b/includes/Services/Forms/RsvFormSubmitResult.php @@ -0,0 +1,38 @@ +errors[] = [ + 'element' => $elementName, + 'code' => $code, + 'message' => $message + ]; + } + + public function hasErrors(): bool { + return count($this->errors) > 0; + } + + public function getErrors(): array { + return $this->errors; + } + + public function setValue(string $name, $value): void { + $this->values[$name] = $value; + } + + public function getValue(string $name) { + return $this->values[$name] ?? null; + } + + public function getValues(): array { + return $this->values; + } + + public function toDto(): array { + + } +} diff --git a/includes/Services/RsvGoogleCalendarService.php b/includes/Services/RsvGoogleCalendarService.php new file mode 100644 index 0000000..12d271b --- /dev/null +++ b/includes/Services/RsvGoogleCalendarService.php @@ -0,0 +1,371 @@ +get_secret('rsv_google_client_secret') ?? ''); + } + + public function get_calendar_id(): string { + return (string) get_option('rsv_google_calendar_id', 'primary'); + } + + public function is_webhook_registered(): bool { + return (bool) get_option('rsv_google_webhook_channel_id'); + } + + // ------------------------------------------------------------------------- + // Encrypted option storage for secrets (tokens, client secret) + // ------------------------------------------------------------------------- + + /** Decrypt a secret stored in wp_options; null if unset or undecryptable. */ + private function get_secret(string $option): ?string { + $stored = get_option($option); + if (!is_string($stored) || $stored === '') { + return null; + } + return RsvCrypto::decrypt($stored); + } + + /** Store a secret in wp_options, encrypted at rest. */ + private function set_secret(string $option, string $value): void { + update_option($option, RsvCrypto::encrypt($value)); + } + + /** Persist the OAuth client secret (encrypted). */ + public function set_client_secret(string $secret): void { + $this->set_secret('rsv_google_client_secret', $secret); + } + + // ------------------------------------------------------------------------- + // OAuth + // ------------------------------------------------------------------------- + + public function get_oauth_url(): string { + return 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([ + 'client_id' => $this->get_client_id(), + 'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'), + 'response_type' => 'code', + 'scope' => 'https://www.googleapis.com/auth/calendar', + 'access_type' => 'offline', + 'prompt' => 'consent', + ]); + } + + public function exchange_code(string $code): bool { + $response = wp_remote_post('https://oauth2.googleapis.com/token', [ + 'body' => [ + 'code' => $code, + 'client_id' => $this->get_client_id(), + 'client_secret' => $this->get_client_secret(), + 'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'), + 'grant_type' => 'authorization_code', + ], + ]); + + $data = json_decode(wp_remote_retrieve_body($response), true); + + if (!isset($data['access_token'])) { + Logger::error('Google Calendar token exchange failed: ' . json_encode($data)); + return false; + } + + $this->set_secret('rsv_google_access_token', $data['access_token']); + $this->set_secret('rsv_google_refresh_token', $data['refresh_token']); + update_option('rsv_google_token_expires', time() + $data['expires_in']); + return true; + } + + public function disconnect(): void { + $this->stop_webhook(); + delete_option('rsv_google_access_token'); + delete_option('rsv_google_refresh_token'); + delete_option('rsv_google_token_expires'); + delete_option('rsv_google_sync_token'); + } + + public function get_access_token(): ?string { + $access_token = $this->get_secret('rsv_google_access_token'); + $refresh_token = $this->get_secret('rsv_google_refresh_token'); + $expires_at = (int) get_option('rsv_google_token_expires', 0); + + if (!$access_token) { + return null; + } + + if (time() > $expires_at - 60) { + $response = wp_remote_post('https://oauth2.googleapis.com/token', [ + 'body' => [ + 'client_id' => $this->get_client_id(), + 'client_secret' => $this->get_client_secret(), + 'refresh_token' => $refresh_token, + 'grant_type' => 'refresh_token', + ], + ]); + + $data = json_decode(wp_remote_retrieve_body($response), true); + + if (isset($data['access_token'])) { + $this->set_secret('rsv_google_access_token', $data['access_token']); + update_option('rsv_google_token_expires', time() + $data['expires_in']); + $access_token = $data['access_token']; + } else { + Logger::error('Google Calendar token refresh failed: ' . json_encode($data)); + // Refresh token is permanently invalid — clear credentials so the + // admin UI shows "reconnect needed" instead of silently failing. + delete_option('rsv_google_access_token'); + delete_option('rsv_google_refresh_token'); + delete_option('rsv_google_token_expires'); + return null; + } + } + + return $access_token; + } + + // ------------------------------------------------------------------------- + // Calendar list + // ------------------------------------------------------------------------- + + public function list_calendars(): array { + $access_token = $this->get_access_token(); + if (!$access_token) { + return []; + } + + $response = wp_remote_get( + 'https://www.googleapis.com/calendar/v3/users/me/calendarList', + ['headers' => ['Authorization' => 'Bearer ' . $access_token]] + ); + + if (is_wp_error($response)) { + return []; + } + + $data = json_decode(wp_remote_retrieve_body($response), true); + return array_map(fn($c) => ['id' => $c['id'], 'summary' => $c['summary']], $data['items'] ?? []); + } + + // ------------------------------------------------------------------------- + // Calendar events + // ------------------------------------------------------------------------- + + public function add_event( + string $calendar_id, + string $summary, + string $start_utc, + string $end_utc, + ?string $attendee_email = null, + ?int $reservation_id = null, + string $status = 'confirmed' + ): void { + $access_token = $this->get_access_token(); + if (!$access_token) { + throw new \RuntimeException('Not connected to Google Calendar.'); + } + + $tz = wp_timezone()->getName(); + $start = (new \DateTime($start_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone()); + $end = (new \DateTime($end_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone()); + + $event = [ + 'summary' => $summary, + 'status' => $status, + 'start' => ['dateTime' => $start->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz], + 'end' => ['dateTime' => $end->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz], + ]; + + if ($attendee_email) { + $event['attendees'] = [['email' => $attendee_email]]; + } + + if ($reservation_id !== null) { + $event['extendedProperties'] = [ + 'private' => ['rsv_reservation_id' => (string) $reservation_id], + ]; + } + + $response = wp_remote_post( + "https://www.googleapis.com/calendar/v3/calendars/{$calendar_id}/events?sendUpdates=all", + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ], + 'body' => json_encode($event), + ] + ); + + if (is_wp_error($response)) { + throw new \RuntimeException('Google Calendar request failed: ' . $response->get_error_message()); + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + if (!isset($body['id'])) { + throw new \RuntimeException('Google Calendar event creation failed: ' . json_encode($body)); + } + } + + // ------------------------------------------------------------------------- + // Push notification webhook + // ------------------------------------------------------------------------- + + public function register_webhook(): array { + $this->stop_webhook(); + + $access_token = $this->get_access_token(); + if (!$access_token) { + return ['error' => 'Not connected to Google Calendar.']; + } + + // Get an initial sync token so the first process_changes() call works. + $this->initialize_sync_token(); + + $channel_id = uniqid('rsv_gcal_', true); + $response = wp_remote_post( + "https://www.googleapis.com/calendar/v3/calendars/{$this->get_calendar_id()}/events/watch", + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'id' => $channel_id, + 'type' => 'web_hook', + 'address' => site_url('/wp-json/reservations/v1/google-calendar-hook'), + ]), + ] + ); + + $data = json_decode(wp_remote_retrieve_body($response), true); + + if (isset($data['id'])) { + update_option('rsv_google_webhook_channel_id', $data['id']); + update_option('rsv_google_webhook_resource_id', $data['resourceId']); + update_option('rsv_google_webhook_expiration', $data['expiration'] / 1000); // ms → s + } else { + Logger::error('Google Calendar webhook registration failed: ' . json_encode($data)); + } + + return $data; + } + + public function stop_webhook(): void { + $channel_id = get_option('rsv_google_webhook_channel_id'); + $resource_id = get_option('rsv_google_webhook_resource_id'); + + if (!$channel_id || !$resource_id) { + return; + } + + $access_token = $this->get_access_token(); + if ($access_token) { + wp_remote_post('https://www.googleapis.com/calendar/v3/channels/stop', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'id' => $channel_id, + 'resourceId' => $resource_id, + ]), + ]); + } + + delete_option('rsv_google_webhook_channel_id'); + delete_option('rsv_google_webhook_resource_id'); + delete_option('rsv_google_webhook_expiration'); + } + + // ------------------------------------------------------------------------- + // Change sync + // ------------------------------------------------------------------------- + + public function initialize_sync_token(): void { + $access_token = $this->get_access_token(); + if (!$access_token) { + return; + } + + $response = wp_remote_get( + 'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([ + 'fields' => 'nextSyncToken', + 'showDeleted' => 'true', + 'singleEvents' => 'true', + ]), + ['headers' => ['Authorization' => 'Bearer ' . $access_token]] + ); + + $data = json_decode(wp_remote_retrieve_body($response), true); + if (isset($data['nextSyncToken'])) { + update_option('rsv_google_sync_token', $data['nextSyncToken']); + } + } + + /** + * Fetch events changed since the last sync and return actions to take. + * + * @return array + */ + public function process_changes(): array { + $access_token = $this->get_access_token(); + if (!$access_token) { + return []; + } + + $sync_token = get_option('rsv_google_sync_token'); + if (!$sync_token) { + $this->initialize_sync_token(); + return []; + } + + $response = wp_remote_get( + 'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([ + 'syncToken' => $sync_token, + 'showDeleted' => 'true', + 'fields' => 'nextSyncToken,items(id,status,extendedProperties)', + ]), + ['headers' => ['Authorization' => 'Bearer ' . $access_token]] + ); + + if (wp_remote_retrieve_response_code($response) === 410) { + // Sync token expired — reinitialise and process next time. + delete_option('rsv_google_sync_token'); + $this->initialize_sync_token(); + return []; + } + + $data = json_decode(wp_remote_retrieve_body($response), true); + + if (isset($data['nextSyncToken'])) { + update_option('rsv_google_sync_token', $data['nextSyncToken']); + } + + $actions = []; + foreach ($data['items'] ?? [] as $event) { + $reservation_id = (int) ($event['extendedProperties']['private']['rsv_reservation_id'] ?? 0); + if (!$reservation_id) { + continue; + } + + $status = $event['status'] ?? ''; + if ($status === 'confirmed') { + $actions[] = ['reservation_id' => $reservation_id, 'action' => 'accept']; + } elseif ($status === 'cancelled') { + $actions[] = ['reservation_id' => $reservation_id, 'action' => 'refuse']; + } + } + + return $actions; + } +} diff --git a/includes/Services/RsvReservationService.php b/includes/Services/RsvReservationService.php new file mode 100644 index 0000000..1c4380c --- /dev/null +++ b/includes/Services/RsvReservationService.php @@ -0,0 +1,131 @@ +repo = new RsvReservationRepository(); + } + + public function get_all(?int $limit = null, int $skip = 0) { + return $this->repo->get_all($limit, $skip); + } + + public function count_all(): int { + return $this->repo->count_all(); + } + + public function get(int $id) { + return $this->repo->get($id); + } + + public function get_detail(int $id): ?array { + return $this->repo->get_detail($id); + } + + public function create(RsvReservation $reservation) { + // Serialise the availability-check + insert per timetable so two + // concurrent bookings for the last free slot can't both pass the + // capacity check and oversell. Locks are taken in a stable order to + // avoid lock-ordering deadlocks and released only after the commit. + $timetable_ids = array_values(array_unique(array_map( + fn($tr) => $tr->timetable_id, + $reservation->timetable_reservations + ))); + sort($timetable_ids); + + foreach ($timetable_ids as $tid) { + if (!Db::acquire_lock($this->booking_lock_name($tid))) { + throw new \RuntimeException('Could not acquire booking lock for timetable ' . $tid); + } + } + + $timetable_reservation_service = new RsvTimetableReservationService(); + + try { + Db::begin_transaction(); + + try { + $reservation_id = $this->repo->insert($reservation->to_array()); + + foreach ($reservation->timetable_reservations as $timetable_reservation) { + $timetable_reservation_service->create($reservation_id, $timetable_reservation); + } + + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + Logger::error($e); + throw new \Exception($e); + } + + // Only now that the rows are durably committed do we let listeners + // (maintainer emails, calendar sync) observe the new reservation. + $timetable_reservation_service->flush_deferred_events(); + + $this->confirmation_state_changed($reservation_id); + + return $reservation_id; + } finally { + foreach (array_reverse($timetable_ids) as $tid) { + Db::release_lock($this->booking_lock_name($tid)); + } + } + } + + private function booking_lock_name(int $timetable_id): string { + return Db::prefix() . 'rsv_booking_' . $timetable_id; + } + + /** Delete a reservation and its dependent timetable reservations and confirmations. */ + public function delete(int $id): void { + $this->repo->delete($id); + } + + public function confirmation_state_changed(int $reservation_id): void { + // Terminal state is persisted, so this is idempotent: once a reservation + // is confirmed or refused, repeat calls (e.g. a Google Calendar re-sync) + // short-circuit and never re-dispatch the closing events. + if ($this->repo->is_resolved($reservation_id)) { + Logger::info('Reservation already resolved: ' . $reservation_id); + return; + } + + // A reservation with no timetable items has nothing to confirm or refuse; + // bailing out avoids mislabelling it as refused. + if ($this->repo->count_subitems($reservation_id) === 0) { + Logger::info('Reservation has not subitems: ' . $reservation_id); + return; + } + + $timetable_service = new RsvTimetableReservationService(); + $all_confirmed = $this->repo->are_subitems_confirmed($reservation_id); + $any_pending = $timetable_service->has_pending_confirmation($reservation_id); + + if ($all_confirmed) { + $this->repo->set_resolved_state($reservation_id, true); + RsvEventDispatcher::dispatch(new RsvReservationConfirmedEvent($reservation_id)); + $form_submit_id = $this->repo->get_form_submit_id($reservation_id); + if ($form_submit_id !== null) { + RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, true)); + } + return; + } + + if (!$any_pending) { + // All items resolved but not all confirmed — at least one refused. + $this->repo->set_resolved_state($reservation_id, false); + RsvEventDispatcher::dispatch(new RsvReservationRefusedEvent($reservation_id)); + $form_submit_id = $this->repo->get_form_submit_id($reservation_id); + if ($form_submit_id !== null) { + RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, false)); + } + return; + } + + Logger::info('Waiting for pending confirmations on reservation: ' . $reservation_id); + } +} diff --git a/includes/Services/RsvTimetableReservationService.php b/includes/Services/RsvTimetableReservationService.php new file mode 100644 index 0000000..b9292de --- /dev/null +++ b/includes/Services/RsvTimetableReservationService.php @@ -0,0 +1,189 @@ + + */ + private array $deferred_events = []; + + public function __construct() { + $this->repo = new RsvTimetableReservationRepository(); + } + + public function get_all(): array { + return $this->repo->get_all(); + } + + public function get(int $id) : RsvTimetableReservation { + return $this->repo->get($id); + } + + private function time_of_day_minutes(DateTime $utc_dt): int { + $dt = (clone $utc_dt)->setTimezone(wp_timezone()); + return (int) $dt->format('G') * 60 + (int) $dt->format('i'); + } + + public function check_availability(int $timetable_id, DateTime $start_utc, DateTime $end_utc): bool { + $overlapping_capacity = (new RsvTimetableCapacityRepository()) + ->get_overlapping_capacity($timetable_id, $start_utc, $end_utc); + + if (count($overlapping_capacity) === 0) { + return false; + } + + $start_min = $this->time_of_day_minutes($start_utc); + $end_min = $this->time_of_day_minutes($end_utc); + + if ((int) $overlapping_capacity[0]->start_time > $start_min) { + return false; + } + + $max = (int) $overlapping_capacity[0]->end_time; + foreach ($overlapping_capacity as $cap) { + if ((int) $cap->start_time > $max) { + return false; + } + $max = max($max, (int) $cap->end_time); + } + + if ($max < $end_min) { + return false; + } + + $capacity = (int) $overlapping_capacity[0]->capacity; + $reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc); + + return count($reservations) < $capacity; + } + + public function is_reservation_valid(RsvTimetableReservation $reservation): bool { + return $this->check_availability( + $reservation->timetable_id, + $reservation->start_utc, + $reservation->end_utc, + ); + } + + private function create_confirmation(int $reservation_id, int $timetable_reservation_id): string { + $code = wp_generate_password(32, false); + $this->repo->insert_confirmation($reservation_id, $timetable_reservation_id, $code); + return $code; + } + + private function set_confirmed_state(string $code, bool $state): int { + $confirmation = $this->repo->get_confirmation($code); + if ($confirmation === null) { + throw new InvalidArgumentException('Confirmation code not found.'); + } + + $this->repo->set_confirmed((int) $confirmation['timetable_reservation_id'], $state); + $this->repo->delete_confirmation($code); + + $reservation_service = new RsvReservationService(); + $reservation_service->confirmation_state_changed($confirmation['reservation_id']); + + return $confirmation['timetable_reservation_id']; + } + + public function accept(string $code): int { + return $this->set_confirmed_state($code, true); + } + + public function refuse(string $code): int { + return $this->set_confirmed_state($code, false); + } + + public function has_pending_confirmation(int $reservation_id): bool { + return $this->repo->has_pending_confirmation($reservation_id); + } + + private function get_confirmation_code(int $reservation_id): string { + $code = $this->repo->get_confirmation_code($reservation_id); + + if ($code === null) { + throw new InvalidArgumentException('No pending confirmation for this reservation.'); + } + + return $code; + } + + public function accept_by_reservation_id(int $reservation_id): void { + $this->set_confirmed_state($this->get_confirmation_code($reservation_id), true); + } + + public function refuse_by_reservation_id(int $reservation_id): void { + $this->set_confirmed_state($this->get_confirmation_code($reservation_id), false); + } + + // TODO: Add requires_confirmation parameter + public function create(int $reservation_id, RsvTimetableReservation $reservation): int { + if (!$this->is_reservation_valid($reservation)) { + throw new InvalidArgumentException(); + } + + $capacity = (new RsvTimetableCapacityRepository())->get_overlapping_capacity( + $reservation->timetable_id, + $reservation->start_utc, + $reservation->end_utc, + ); + $needs_confirmation = array_filter($capacity, fn($c) => (bool) $c->requires_confirmation); + + $insert_array = $reservation->to_array(); + $insert_array['reservation_id'] = $reservation_id; + $insert_array['is_confirmed'] = empty($needs_confirmation); + + $id = $this->repo->insert($insert_array); + + if (!empty($needs_confirmation)) { + $maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($reservation->timetable_id); + + if ($maintainer_email) { + $code = $this->create_confirmation($reservation_id, $id); + $this->deferred_events[] = new RsvTimetableReservationPendingEvent( + $reservation_id, + $reservation, + $code, + $maintainer_email + ); + } + } else { + $reservation_service = new RsvReservationService(); + $reservation_service->confirmation_state_changed($reservation_id); + } + + $this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation); + + return $id; + } + + /** + * Dispatch (and clear) the events buffered by create(). Callers must invoke + * this *after* committing the transaction that wraps create(). + */ + public function flush_deferred_events(): void { + $events = $this->deferred_events; + $this->deferred_events = []; + foreach ($events as $event) { + RsvEventDispatcher::dispatch($event); + } + } + + public function get_by_timetable(int $timetable_id, ?int $limit = null, int $skip = 0): array { + return $this->repo->get_by_timetable($timetable_id, $limit, $skip); + } + + public function count_by_timetable(int $timetable_id): int { + return $this->repo->count_by_timetable($timetable_id); + } + + public function get_reservations_on_date(int $timetable_id, DateTime $date_utc): array { + return $this->repo->get_reservations_on_date($timetable_id, $date_utc); + } +} diff --git a/includes/Services/RsvTimetableService.php b/includes/Services/RsvTimetableService.php new file mode 100644 index 0000000..d4d75b9 --- /dev/null +++ b/includes/Services/RsvTimetableService.php @@ -0,0 +1,109 @@ +repo = new RsvTimetableRepository(); + } + + public function get_all(?int $limit = null, int $skip = 0): array { + return $this->repo->get_all($limit, $skip); + } + + public function count_all(): int { + return $this->repo->count_all(); + } + + public function get(int $id): ?RsvTimetable { + return $this->repo->get($id); + } + + public function create(RsvTimetable $timetable): int { + return $this->repo->create($timetable); + } + + public function update(int $id, RsvTimetable $timetable): int { + return $this->repo->update($id, $timetable); + } + + public function get_all_maintainer_emails(): array { + return $this->repo->get_all_maintainer_emails(); + } + + public function set_google_calendar_id(int $id, ?string $calendar_id): void { + $this->repo->set_google_calendar_id($id, $calendar_id); + } + + public function delete(int $id): int { + Logger::info('Deleting timetable: ' . $id); + return $this->repo->delete($id); + } + + private function datetime_to_minutes(DateTime $dt): int { + $d = $dt->setTimezone(wp_timezone()); + return (int) $d->format('G') * 60 + (int) $d->format('i'); + } + + /** + * @return array Availability on date + */ + public function get_availability_on_date(int $timetable_id, int $block_length, DateTime $date) : array { + /** + * Get available seats for the given date + * Keeps two stacks: capacities & reservations + * + * Goes from left to right in reservations and pushes to stack + */ + $capacities = (new RsvTimetableCapacityRepository())->get_capacities_for_date($timetable_id, $date); + $reservations = (new RsvTimetableReservationService())->get_reservations_on_date($timetable_id, $date); + + $capacity_stack = []; + $reservation_stack = []; + + $capacity_index = 0; + $reservation_index = 0; + + $blocks_count = 24 * 60 / $block_length; + $blocks = array_fill(0, $blocks_count, 0); + + $availabilities = []; + $availability_idx = 0; + + for($i = 0; $i < $blocks_count; $i++) { + $reservation_stack = array_filter($reservation_stack, fn($reservation) => + $this->datetime_to_minutes($reservation->end_utc) > $i * $block_length + ); + + $capacity_stack = array_filter($capacity_stack, fn($capacity) => + $capacity->end_time > $i * $block_length + ); + + while($reservation_index < count($reservations) && $this->datetime_to_minutes($reservations[$reservation_index]->start_utc) <= $i * $block_length) { + $reservation_stack[] = $reservations[$reservation_index]; + $reservation_index++; + } + + while($capacity_index < count($capacities) && $capacities[$capacity_index]->start_time <= $i * $block_length) { + $capacity_stack[] = $capacities[$capacity_index]; + $capacity_index++; + } + + $total_capacity = array_sum(array_map(fn($x) => $x->capacity, $capacity_stack)); + + if ($total_capacity > 0) { + if(count($availabilities) === $availability_idx) { + $availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []); + } + + $availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack)); + } else if($total_capacity === 0 && count($availabilities) !== $availability_idx) { + $availability_idx++; + } + } + + return $availabilities; + } +} diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php new file mode 100644 index 0000000..ce15e23 --- /dev/null +++ b/includes/Views/RsvFormsPage.php @@ -0,0 +1,397 @@ + + + handlers); + $elements_with_ids = []; + $next_id = 1; + $timetables = (new RsvTimetableService())->get_all(); + ?> + +

Formuláře

+
+
+
+
+
+

Přidat formulář

+ +
+ + + text('name', 'Název')->render(); ?> +
+
+ +
+

+ +

+
+
+ +
+
+
+ +
+
+
+ + + + + handlers); + + $repo = new RsvFormDefinitionRepository(); + $form_def = $repo->get($id); + + if ($form_def === null) { + echo '

Form definition not found.

'; + return; + } + + $definition = $form_def['definition'] ?? []; + $raw_elements = array_values($definition['elements'] ?? []); + + $elements_with_ids = array_map(function (array $el, int $idx): array { + return array_merge($el, ['id' => $idx + 1]); + }, $raw_elements, array_keys($raw_elements)); + + $next_id = count($elements_with_ids) + 1; + $timetables = (new RsvTimetableService())->get_all(); + + ?> +

Edit Form:

+ ← Back to Forms +
+ +
+ + text('name', 'Name', '', true, $form_def['name']) + ->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '') + ->render(); + ?> +
+ +
+

Form Elements

+

Define the fields that will appear in this form.

+
+

+ +

+

+ +

+ + + + 'success', 'message' => 'Google Calendar connected successfully.']; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) { + if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) { + wp_die('Security check failed.'); + } + + if (isset($_POST['rsv_disconnect'])) { + $service->disconnect(); + $notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.']; + } elseif (isset($_POST['rsv_register_webhook'])) { + $result = $service->register_webhook(); + $notice = isset($result['id']) + ? ['type' => 'success', 'message' => 'Webhook registered.'] + : ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))]; + } elseif (isset($_POST['rsv_stop_webhook'])) { + $service->stop_webhook(); + $notice = ['type' => 'success', 'message' => 'Webhook stopped.']; + } else { + update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? '')); + update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary')); + // Only overwrite the (encrypted) client secret when a new value is + // supplied — the field renders blank, so otherwise saving any other + // setting would wipe the stored secret. + $client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? ''); + if ($client_secret !== '') { + $service->set_client_secret($client_secret); + } + $notice = ['type' => 'success', 'message' => 'Settings saved.']; + } + } + + $connected = $service->is_google_connected(); + $webhook_registered = $service->is_webhook_registered(); + $webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0); + $client_id = esc_attr(get_option('rsv_google_client_id', '')); + $cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary')); + $oauth_url = esc_url($service->get_oauth_url()); + ?> +
+

Google Calendar

+ + +
+

+
+ + +
+ + +

OAuth Credentials

+

Create a project in Google Cloud Console, enable the Google Calendar API, and create OAuth 2.0 credentials. Set the authorised redirect URI to .

+ + + + + + + + + + + + + + +
+ +

Use primary for the account's main calendar, or paste a specific calendar ID from Google Calendar settings.

+
+ + + +
+ +

Connection

+ + +

✔ Connected to Google Calendar.

+ + +

✘ Not connected.

+ Connect with Google +

You must save the Client ID and Secret first.

+ + + +
+

Webhook

+

The webhook lets Google Calendar notify this site when you confirm or cancel a reservation event, so the reservation state is updated automatically.

+ +

Webhook URL:

+ + +
+

HTTPS required. Google Calendar only accepts webhook URLs served over HTTPS. This site is currently on HTTP, so webhook registration will be rejected by Google. Use a publicly accessible HTTPS address in production.

+
+ + + +

✔ Webhook active. + + Expires . + +

+ + + +

✘ Webhook not registered.

+ + + +
+
+ +

Form Submissions

+ +
+
+ + + + +

Timetables

+
+
+
+
+
+

Add timetable

+ + get_all_maintainer_emails(); + $existing_emails_json = json_encode($existing_emails); + ?> + + + + +
+ + text('name', 'Name', 'Name of the timetable that can be reserved.', true) + ->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1) + ->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions') + ->submit('Add Timetable') + ->render(); + ?> +
+ +
+
+
+
+
+
+ +
+ +
+
+
+ date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d')); + $form->group('Availability Range', fn($g) => $g + ->time('start_time', 'Start') + ->time('end_time', 'End') + ); + $form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1); + $form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true); + $form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true); + $form->custom('Is Repeating Event', function() { + return ' + +

If the capacity is available repeatingly. For example: repeat each monday every week.

+ '; + }); + $form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true); + $form->custom('Apply to Days', function() { + return ' + + + + + + + + + + + + + + + + + + + + + +
MondayTuesdayWednesdayThursdayFridaySaturdaySunday
'; + }); + $form->submit('Create Capacity', 'button-primary', 'submit'); + + ?> +
+ output(); + + ?> +
+ get($id); + $gcal_service = new RsvGoogleCalendarService(); + $gcal_connected = $gcal_service->is_google_connected(); + $current_calendar_id = $timetable->google_calendar_id ?? null; + $existing_emails = $timetable_service->get_all_maintainer_emails(); + ?> + +

Settings

+ + + + + +
+ + text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name) + ->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1) + ->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions') + ->custom('Google Calendar', function() use ($gcal_connected) { + if (!$gcal_connected) { + return'

Not connected to Google Calendar. + Connect in settings → +

'; + } else { + return ' +

Sync reservations to this calendar.

'; + } + }) + ->submit('Save Settings', 'button-primary', 'submit') + ->output(); + ?> +
+ + + +

Capacity

+ +

Define capacities for timetable.

+ + + + +
+ + + get($id); + if ($timetable === null) { + echo '

Timetable not found.

'; + return; + } + ?> +

name) ?>

+ ← Back to Timetables +
+ + + + + maintainer_email): ?> + + +
Namename) ?>
Block sizeblock_size) ?> minutes
Maintainer emailmaintainer_email) ?>
+ +

Reservations

+
+ + 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); + } +} diff --git a/modules/Database/README.md b/modules/Database/README.md new file mode 100644 index 0000000..53fa377 --- /dev/null +++ b/modules/Database/README.md @@ -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. diff --git a/modules/Forms/RsvFormBuilder.php b/modules/Forms/RsvFormBuilder.php new file mode 100644 index 0000000..63fe90b --- /dev/null +++ b/modules/Forms/RsvFormBuilder.php @@ -0,0 +1,302 @@ + 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[] 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 in the table + // ------------------------------------------------------------------------- + + public function text( + string $id, + string $label, + string $desc = '', + bool $required = false, + string $value = '' + ): static { + $req = $required ? 'required' : ''; + $ctrl = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + return $this->row($id, $label, $ctrl, $desc); + } + + public function checkbox( + string $id, + string $label, + string $desc = '', + bool $checked = false + ): static { + $c = $checked ? 'checked' : ''; + $ctrl = ''; + return $this->row($id, $label, $ctrl, $desc); + } + + /** + * @param array $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 = ''; + 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 = ''; + return $this->row($id, $label, $ctrl, $desc); + } + + public function custom(string $label, callable $fn) : static { + $this->rows[] = '' + . '' . esc_html($label) . '' + . '' . $fn() . '' + . ''; + 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 . + * + * 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[] = '' + . '' . esc_html($label) . '' + . '' . $group->render() . '' + . ''; + return $this; + } + + // ------------------------------------------------------------------------- + // Non-field outputs + // ------------------------------------------------------------------------- + + /** Hidden input — no row, emitted before the table. */ + public function hidden(string $id, string|int $value): static + { + $this->before[] = ''; + return $this; + } + + /** + * 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 .= '