PodsFieldSource maps hidden, read_only, admin_only and restrict_role/-capability like Pods' own view: hidden -> new status HIDDEN, read_only -> READONLY, restricted and not permitted -> READONLY with read_only_restricted, else HIDDEN. HIDDEN fields are not described, read out, queried or written (they look unknown) but stay in the schema for the media usage search; write() re-checks before Pods' save(). Required-but-restricted fields no longer block a create. Pods is not active on DDEV: covered by unit tests with a field-option fixture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
192 lines
6.7 KiB
PHP
192 lines
6.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Integrations\WordPress\Schema;
|
|
|
|
use DateTimeImmutable;
|
|
|
|
/**
|
|
* Checks field values against the detected schema before anything is written.
|
|
*/
|
|
final class FieldValidator
|
|
{
|
|
private const LIST_TYPES = ['relation', 'file', 'choice', 'array'];
|
|
|
|
/**
|
|
* @param array<string, FieldDefinition> $definitions
|
|
* @param array<string, mixed> $values
|
|
* @return array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}
|
|
*/
|
|
public function validate(array $definitions, array $values, bool $isCreate): array
|
|
{
|
|
$clean = [];
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
foreach ($values as $key => $value) {
|
|
$key = (string) $key;
|
|
$field = $definitions[$key] ?? null;
|
|
if ($field === null || $field->status === FieldDefinition::HIDDEN) {
|
|
$errors[$key] = 'Unbekanntes Feld; verfügbare Felder über lit/content-types (direkt: '
|
|
. 'lit-content-types) abrufen.';
|
|
continue;
|
|
}
|
|
|
|
if ($field->status === FieldDefinition::READONLY) {
|
|
$errors[$key] = 'Feld ist schreibgeschützt.';
|
|
continue;
|
|
}
|
|
|
|
$error = $value === null ? null : $this->check($field, $value);
|
|
if ($error !== null) {
|
|
$errors[$key] = $error;
|
|
continue;
|
|
}
|
|
|
|
if ($field->status === FieldDefinition::UNKNOWN) {
|
|
$warnings[] = sprintf(
|
|
'Feld "%s" hat keinen bekannten Typ; der Wert wurde unverändert gespeichert.',
|
|
$key
|
|
);
|
|
}
|
|
|
|
$clean[$key] = $value;
|
|
}
|
|
|
|
foreach ($definitions as $key => $field) {
|
|
// A field the user cannot write (readonly, hidden) cannot be required from them either.
|
|
$writable = in_array($field->status, [FieldDefinition::WRITABLE, FieldDefinition::UNKNOWN], true);
|
|
if (! $field->required || ! $writable || isset($errors[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$provided = array_key_exists($key, $clean);
|
|
if ($isCreate && (! $provided || $this->isEmpty($clean[$key]))) {
|
|
$errors[$key] = 'Pflichtfeld fehlt.';
|
|
} elseif (! $isCreate && $provided && $this->isEmpty($clean[$key])) {
|
|
$errors[$key] = 'Pflichtfeld darf nicht geleert werden.';
|
|
}
|
|
}
|
|
|
|
return ['values' => $clean, 'errors' => $errors, 'warnings' => $warnings];
|
|
}
|
|
|
|
private function check(FieldDefinition $field, mixed $value): ?string
|
|
{
|
|
if ($field->multiple && in_array($field->type, self::LIST_TYPES, true)) {
|
|
if (! is_array($value) || ! array_is_list($value)) {
|
|
return 'Liste erwartet.';
|
|
}
|
|
|
|
if ($field->type === 'array') {
|
|
return null;
|
|
}
|
|
|
|
foreach ($value as $item) {
|
|
$error = $this->checkScalar($field, $item);
|
|
if ($error !== null) {
|
|
return $error;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return $this->checkScalar($field, $value);
|
|
}
|
|
|
|
private function checkScalar(FieldDefinition $field, mixed $value): ?string
|
|
{
|
|
switch ($field->type) {
|
|
case 'string':
|
|
case 'text':
|
|
case 'password':
|
|
return is_string($value) ? null : 'Text erwartet.';
|
|
case 'integer':
|
|
return is_int($value) ? null : 'Ganzzahl erwartet.';
|
|
case 'number':
|
|
return (is_int($value) || is_float($value)) ? null : 'Zahl erwartet.';
|
|
case 'boolean':
|
|
return is_bool($value) ? null : 'true oder false erwartet.';
|
|
case 'date':
|
|
return $this->matchesFormat($value, 'Y-m-d') ? null : 'Datum im Format JJJJ-MM-TT erwartet.';
|
|
case 'datetime':
|
|
return $this->matchesFormat($value, 'Y-m-d H:i:s')
|
|
? null
|
|
: 'Datum und Uhrzeit im Format JJJJ-MM-TT HH:MM:SS erwartet.';
|
|
case 'time':
|
|
return $this->matchesFormat($value, 'H:i:s') ? null : 'Uhrzeit im Format HH:MM:SS erwartet.';
|
|
case 'choice':
|
|
return $this->checkChoice($field, $value);
|
|
case 'relation':
|
|
case 'file':
|
|
return $this->checkRelation($field, $value);
|
|
case 'array':
|
|
return (is_array($value) && array_is_list($value)) ? null : 'Liste erwartet.';
|
|
case 'object':
|
|
return is_array($value) ? null : 'Objekt erwartet.';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function checkChoice(FieldDefinition $field, mixed $value): ?string
|
|
{
|
|
$allowed = array_column($field->choices ?? [], 'value');
|
|
|
|
return is_string($value) && in_array($value, $allowed, true)
|
|
? null
|
|
: sprintf('Erlaubte Werte: %s.', implode(', ', $allowed));
|
|
}
|
|
|
|
/**
|
|
* The target must exist and be readable for the current user; an unreadable target gets
|
|
* the same answer as a missing one and its actual post type is never named (no probing).
|
|
*/
|
|
private function checkRelation(FieldDefinition $field, mixed $value): ?string
|
|
{
|
|
if (! is_int($value) || $value <= 0) {
|
|
return 'ID (positive Ganzzahl) erwartet.';
|
|
}
|
|
|
|
$actual = get_post_type($value);
|
|
$readable = $actual !== false && current_user_can('read_post', $value);
|
|
$objectType = $field->related['object_type'] ?? '';
|
|
if ($objectType === 'media') {
|
|
return $readable && $actual === 'attachment'
|
|
? null
|
|
: sprintf('%d ist kein für dich lesbares Medium der Mediathek.', $value);
|
|
}
|
|
|
|
if ($objectType === 'post_type') {
|
|
if (! $readable) {
|
|
return sprintf('Eintrag %d existiert nicht oder ist für dich nicht lesbar.', $value);
|
|
}
|
|
|
|
$expected = $field->related['name'] ?? null;
|
|
if ($expected !== null && $actual !== $expected) {
|
|
return sprintf('Eintrag %d ist kein Eintrag vom Typ %s.', $value, $expected);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function matchesFormat(mixed $value, string $format): bool
|
|
{
|
|
if (! is_string($value)) {
|
|
return false;
|
|
}
|
|
|
|
$parsed = DateTimeImmutable::createFromFormat('!' . $format, $value);
|
|
|
|
return $parsed !== false && $parsed->format($format) === $value;
|
|
}
|
|
|
|
private function isEmpty(mixed $value): bool
|
|
{
|
|
return $value === null || $value === '' || $value === [];
|
|
}
|
|
}
|