$postarr Felder fuer wp_insert_post() inklusive post_author; post_content wird gefiltert. * @return WriteResult * @throws \RuntimeException Wenn WordPress den Beitrag nicht anlegt. */ public function insert( array $postarr ): WriteResult { $postarr['post_content'] = self::prepare_body( (string) ( $postarr['post_content'] ?? '' ) ); $post_id = $this->write( 0, 0, static function () use ( $postarr ) { return wp_insert_post( wp_slash( $postarr ), true ); } ); return $this->read_back( $post_id ); } /** * Ersetzt Felder eines bestehenden Beitrags. * * @param int $post_id Beitrag. * @param array $fields Zu aendernde Felder, post_content wird gefiltert. * @param int $author_id Autor der Revision, die WordPress dabei anlegt. * @return WriteResult * @throws \RuntimeException Wenn WordPress den Beitrag nicht aktualisiert. */ public function update( int $post_id, array $fields, int $author_id ): WriteResult { $previous = self::read_body( $post_id ); if ( null === $previous ) { throw new \RuntimeException( 'the object disappeared while it was updated' ); } // Sicherung ohne Revisionen (technisches Konzept 6.3): nur der letzte // Stand, bewusst kein Verlauf. Auf Installationen mit // WP_POST_REVISIONS = false ist das der einzige Rueckweg. update_post_meta( $post_id, self::PREVIOUS_BODY_META, wp_slash( $previous ) ); if ( array_key_exists( 'post_content', $fields ) ) { $fields['post_content'] = self::prepare_body( (string) $fields['post_content'] ); } $fields['ID'] = $post_id; $result = $this->write( $post_id, $author_id, static function () use ( $fields ) { return wp_update_post( wp_slash( $fields ), true ); } ); return $this->read_back( $result ); } /** * Legt eine Revision mit neuem Inhalt an, ohne den Beitrag zu aendern. * * Fuer „Erweitern": Die Fassung liegt im Revisionsverlauf, bis ein Mensch * sie wiederherstellt. Zuerst wird der aktuelle Stand als Revision * gesichert (No-op, wenn er schon die juengste ist) — sonst fehlt im * Revisionsfenster der Vergleich, und der Editor zeigt den Link * „Revisionen" erst ab zwei Eintraegen. * * Das Datum muss juenger sein als die letzte Aenderung des Beitrags: * Eine Revision mit demselben post_modified_gmt gilt im Revisionsfenster * als „Aktuelle Revision", und deren Wiederherstellen-Button ist gesperrt. * * _wp_put_post_revision() ist core-intern, aber der einzige Weg zu einer * Revision mit fremdem Inhalt; sie kopiert revisionierte Meta wie bei jeder * Core-Revision. Sie slasht selbst, deshalb hier kein wp_slash(). * * @param \WP_Post $post Bestehender Beitrag. * @param string $content Neuer Body, wird gefiltert. * @param string $excerpt Neuer Auszug, leer = bisheriger bleibt. * @param int $author_id Benutzer, dem die Revision zugeordnet wird. * @return WriteResult Ergebnis mit der ID der Revision. * @throws \RuntimeException Wenn WordPress keine Revision anlegt. */ public function put_revision( \WP_Post $post, string $content, string $excerpt, int $author_id ): WriteResult { $data = get_post( $post->ID, ARRAY_A ); if ( ! is_array( $data ) ) { throw new \RuntimeException( 'the object disappeared while it was updated' ); } $stamp = max( time(), (int) strtotime( $post->post_modified_gmt . ' UTC' ) + 1 ); $gmt = gmdate( 'Y-m-d H:i:s', $stamp ); $data['post_content'] = self::prepare_body( $content ); $data['post_modified_gmt'] = $gmt; $data['post_modified'] = get_date_from_gmt( $gmt ); if ( '' !== $excerpt ) { $data['post_excerpt'] = $excerpt; } $revision_id = $this->write( $post->ID, $author_id, static function () use ( $post, $data ) { wp_save_post_revision( $post->ID ); return _wp_put_post_revision( $data ); } ); return $this->read_back( $revision_id ); } /** * Liest post_content direkt aus der Datenbank. * * Bewusst kein get_post(): Ground Truth ist die Datenbank, nicht der * Objekt-Cache (technisches Konzept K14). * * @param int $post_id Beitrag. * @return string|null Null, wenn es den Beitrag nicht gibt. */ public static function read_body( int $post_id ): ?string { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Zuruecklesen am Cache vorbei ist hier der Zweck. $body = $wpdb->get_var( $wpdb->prepare( "SELECT post_content FROM {$wpdb->posts} WHERE ID = %d", $post_id ) ); return null === $body ? null : (string) $body; } /** * Der Hash, den der Server vergleicht: sha256 ueber den Body, keine Normalisierung. * * @param string $body Gespeicherter Body. * @return string Lowercase hex. */ public static function hash( string $body ): string { return hash( 'sha256', $body ); } /** * Ob gerade ein eigener Schreibvorgang laeuft. * * @return bool */ public static function is_writing(): bool { return self::$writing; } /** * Filtert den Body vor dem Schreiben. * * Immer, unabhaengig vom Benutzer: kses_init haengt an set_current_user und * schaltet die Filter fuer Benutzer mit unfiltered_html ab. Ohne * Eigenfilterung hinge das Speicherergebnis — und damit der Hash — daran, * ob der Cron oder ein Administrator von Hand abholt (technisches * Konzept K13). * * @param string $html Gelieferter Body. * @return string */ private static function prepare_body( string $html ): string { /** * Ob der gelieferte Body vor dem Speichern durch wp_kses_post() laeuft. * * Der Body kommt bereits enger gefiltert von Aivor. Abschalten nur, * wenn ein anderer Filter dieselbe Aufgabe uebernimmt. * * @param bool $filter Standard true. */ if ( ! apply_filters( 'aivor_content_pre_kses', true ) ) { return $html; } return wp_kses_post( $html ); } /** * Fuehrt einen Schreibvorgang aus und ordnet dabei entstehende Revisionen zu. * * Der Benutzerkontext bleibt unangetastet. WordPress traegt als Autor einer * Revision den aktuellen Benutzer ein, im Cron also niemanden; der Filter * setzt fuer die Revisionen genau dieses Beitrags stattdessen den Autor * des Jobs bzw. des Beitrags ein. Er haengt nur waehrend des Schreibens. * * @param int $post_id Beitrag, dessen Revisionen zugeordnet werden, 0 fuer keinen. * @param int $author_id Autor dieser Revisionen, 0 fuer den aktuellen Benutzer. * @param callable $callback Schreibvorgang, liefert die Beitrags-ID oder WP_Error. * @return int|\WP_Error */ private function write( int $post_id, int $author_id, callable $callback ) { $attribute = static function ( $data ) use ( $post_id, $author_id ) { if ( $post_id > 0 && $author_id > 0 && is_array( $data ) && 'revision' === ( $data['post_type'] ?? '' ) && (int) ( $data['post_parent'] ?? 0 ) === $post_id ) { $data['post_author'] = $author_id; } return $data; }; self::$writing = true; add_filter( 'wp_insert_post_data', $attribute ); try { return $callback(); } finally { remove_filter( 'wp_insert_post_data', $attribute ); self::$writing = false; } } /** * Prueft das Ergebnis, liest zurueck und hasht. * * @param int|\WP_Error $result Rueckgabe von wp_insert_post() bzw. wp_update_post(). * @return WriteResult * @throws \RuntimeException Wenn nicht geschrieben wurde. */ private function read_back( $result ): WriteResult { if ( is_wp_error( $result ) ) { throw new \RuntimeException( 'the object could not be updated: ' . esc_html( $result->get_error_message() ) ); } $post_id = (int) $result; if ( 0 === $post_id ) { throw new \RuntimeException( 'the object could not be updated: WordPress returned no post ID' ); } clean_post_cache( $post_id ); $body = self::read_body( $post_id ); if ( null === $body ) { throw new \RuntimeException( 'the object disappeared while it was updated' ); } return new WriteResult( $post_id, $body, self::hash( $body ) ); } }