'positive Ganzzahl erwartet'] ); } $post = get_post($id); if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) { return self::notFound($id); } if (! current_user_can('edit_post', $id)) { return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id)); } if (! $allowTrashed && $post->post_status === 'trash') { return Errors::invalidInput( sprintf('Eintrag %d liegt im Papierkorb.', $id), [$field => 'Eintrag liegt im Papierkorb'], 'Eintrag zuerst in WordPress wiederherstellen.' ); } return $post; } /** The refusal for an ID that names no entry the tools manage – the same for every such ID. */ public static function notFound(int $id): WP_Error { return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); } /** * A scheduled entry whose publication time has passed (WordPress' cron missed it) or is * less than a minute away: wp_insert_post() turns "future" into "publish" on any save when * the date is within the next MINUTE_IN_SECONDS, so writes must not touch such an entry. * A zero post_date_gmt (never set) falls back to the local post_date, like core does. */ public static function missedSchedule(WP_Post $post): bool { if ($post->post_status !== 'future') { return false; } $gmt = (string) $post->post_date_gmt; if ($gmt === '' || $gmt === self::ZERO_DATE) { $local = (string) $post->post_date; $gmt = $local === '' || $local === self::ZERO_DATE ? '' : (string) get_gmt_from_date($local); } $time = $gmt === '' ? false : strtotime($gmt . ' UTC'); return $time !== false && $time <= time() + self::PUBLISH_WINDOW; } /** * For internal writes (rollbacks, clone rows): wp_update_post() would publish a missed * schedule (see missedSchedule()), so the entry is re-read and such a save is refused. * $message is the internal log text of the RuntimeException. * * @throws RuntimeException */ public static function assertNoMissedSchedule(int $postId, string $message): void { $post = get_post($postId); if ($post instanceof WP_Post && self::missedSchedule($post)) { throw new RuntimeException($message); } } /** * Refuses a write that would change the status as a side effect (spec §2: status is only * changed on explicit request) – currently a missed schedule, which any save would publish. */ public static function statusStable(WP_Post $post, string $field = 'post_id'): ?WP_Error { if (! self::missedSchedule($post)) { return null; } return Errors::invalidInput( sprintf( 'Eintrag %d ist geplant, der Veröffentlichungszeitpunkt ist aber schon verstrichen; jedes Speichern ' . 'würde ihn sofort veröffentlichen. Nichts wurde gespeichert.', (int) $post->ID ), [$field => 'geplanter Zeitpunkt verstrichen'], 'Beim Nutzer nachfragen: jetzt veröffentlichen (lit/post-publish, direkt: lit-post-publish) oder neu ' . 'planen (lit/post-schedule über mcp-adapter-execute-ability); danach die Änderung wiederholen.' ); } /** * The brief declares this `: object|WP_Error`, but `object` already covers every * class type, so PHP refuses `object` inside a union ("Type object cannot be * part of a union type" – fatal at compile time). WP_Error is itself an object, * so a plain `object` return type is accurate and still lets callers branch with * `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above. */ public static function postType(mixed $postType, ContentSchema $schema, string $cap): object { $name = is_string($postType) ? $postType : ''; $object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null; if (! is_object($object)) { return Errors::notFound( sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name), 'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.' ); } $capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap; if (! current_user_can($capability)) { return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name)); } return $object; } /** * Whether a list/search query for this post type must stay restricted to the current * user's own entries because they lack edit_others_posts. Filtering per row after the * query (e.g. current_user_can('edit_post', $id)) is not enough on its own: without this, * a restricted user's query still runs unrestricted, so a page can come back empty (or * short) with a next_cursor that quietly reveals other users' entries exist without ever * showing them – the same defect ContentQueryAbility guards against for content-query. * * @return int|null The current user id when restricted, null when the query may run * unrestricted (author filter must then be left out entirely). */ public static function ownPostsOnly(string $postType): ?int { $typeObject = get_post_type_object($postType); $caps = is_object($typeObject) && isset($typeObject->cap) ? $typeObject->cap : null; $capability = $caps !== null && isset($caps->edit_others_posts) ? (string) $caps->edit_others_posts : 'edit_others_posts'; return current_user_can($capability) ? null : get_current_user_id(); } public static function id(mixed $value): ?int { if (is_int($value)) { return $value > 0 ? $value : null; } if (is_string($value) && ctype_digit($value) && (int) $value > 0) { return (int) $value; } return null; } }