client->fetch_next( $job->aivor_job_id ); if ( null === $delivery ) { return self::NOTHING; } $title = trim( wp_strip_all_tags( (string) ( $delivery['title'] ?? '' ) ) ); $draft_hash = AivorClient::is_uuid( $delivery['draft_hash'] ?? null ) ? strtolower( (string) $delivery['draft_hash'] ) : ''; $site_hash = AivorClient::is_uuid( $delivery['site_hash'] ?? null ) ? strtolower( (string) $delivery['site_hash'] ) : ''; if ( '' === $title ) { // Der Server hat den Draft bereits als abgeholt verbucht. Still // verwerfen hiesse, den Text spurlos zu verlieren. EventLog::add( EventLog::TYPE_DISCARDED, sprintf( /* translators: %s: identifier of the Aivor draft */ __( 'Aivor delivered an article without a title (draft %s). It was discarded. Release the article again in Aivor to retry.', 'aivor-content-import' ), '' !== $draft_hash ? $draft_hash : '—' ), array( 'level' => EventLog::LEVEL_ERROR, 'job_id' => $job->id, ) ); return self::DISCARDED; } if ( '' !== $site_hash && $site_hash !== $job->site_hash ) { $this->jobs->update_fields( $job->id, array( 'site_hash' => $site_hash ) ); } if ( '' !== $draft_hash ) { $existing = DraftMapping::find_post_id( $draft_hash ); if ( $existing > 0 ) { // Der Server hat einen schon importierten Draft erneut geliefert. // Neu anzulegen hinterliesse ein verwaistes Duplikat. EventLog::add( EventLog::TYPE_IMPORTED, __( 'Aivor delivered this article again, but it already exists here. No duplicate was created.', 'aivor-content-import' ), array( 'job_id' => $job->id, 'post_id' => $existing, ) ); $this->back_channel->report_published( $existing, true ); return self::REDELIVERED; } if ( self::already_waiting( $job, $draft_hash ) ) { return self::REDELIVERED; } DraftMapping::release_trashed( $draft_hash ); } $content = HtmlNormalizer::for_import( (string) ( $delivery['content'] ?? '' ) ); $excerpt = trim( wp_strip_all_tags( (string) ( $delivery['introduction'] ?? '' ) ) ); $description = trim( wp_strip_all_tags( (string) ( $delivery['meta_description'] ?? '' ) ) ); $replaces = is_string( $delivery['replaces_url'] ?? null ) ? trim( $delivery['replaces_url'] ) : ''; // „Erweitern": Aivor liefert die erweiterte Fassung eines bestehenden // Artikels mit dessen Adresse. Ein Live-Artikel aendert sich nur durch // einen Menschen: Die Fassung kommt als Revision an den Beitrag. Geht // das nicht, wird sie ein eigener Entwurf, und das Protokoll nennt den // Grund — erst nach dem Anlegen, damit der Eintrag auf den neuen Beitrag // verweist. $fallback_note = ''; if ( '' !== $replaces ) { $extension = $this->extend( $job, $replaces, $content, $excerpt, $description, $draft_hash, $site_hash ); if ( $extension['post_id'] > 0 ) { $this->jobs->update_fields( $job->id, array( 'last_import' => time(), 'error' => '', ) ); EventLog::add( EventLog::TYPE_REPLACED, __( 'Aivor delivered an extended version of this article and saved it as a revision. The live article does not change until you restore the revision. Open the article to compare.', 'aivor-content-import' ), array( 'job_id' => $job->id, 'post_id' => $extension['post_id'], ) ); return self::EXTENDED; } $fallback_note = $extension['note']; } $post_id = $this->create( $job, $delivery, $title, $content, $excerpt, $description, $draft_hash, $site_hash ); if ( '' !== $fallback_note ) { EventLog::add( EventLog::TYPE_REPLACED, $fallback_note, array( 'level' => EventLog::LEVEL_WARNING, 'job_id' => $job->id, 'post_id' => $post_id, ) ); } $this->jobs->update_fields( $job->id, array( 'last_import' => time(), 'error' => '', ) ); EventLog::add( EventLog::TYPE_IMPORTED, self::describe_outcome( $post_id ), array( 'job_id' => $job->id, 'post_id' => $post_id, ) ); if ( '' !== $draft_hash ) { $this->back_channel->report_published( $post_id, false ); } return self::IMPORTED; } /** * Legt einen neuen Beitrag an. * * Erst als Entwurf mit allem, was dazugehoert — Titelbild, Kategorie, * Schlagwoerter, Meta-Description —, und erst danach gegebenenfalls zur * Pruefung vorgelegt. Veroeffentlicht wird von Hand; Plugins, die dabei * etwas tun (Social-Sharing, SEO-Indexe), sehen so einen vollstaendigen * Beitrag. * * @param Job $job Der Job. * @param array $delivery Lieferung. * @param string $title Titel. * @param string $content Body. * @param string $excerpt Einleitung. * @param string $description Meta-Description. * @param string $draft_hash Schluessel des Drafts, leer im Legacy-Pfad. * @param string $site_hash Site-Hash. * @return int Beitrags-ID. * @throws \RuntimeException Wenn WordPress den Beitrag nicht anlegt. */ private function create( Job $job, array $delivery, string $title, string $content, string $excerpt, string $description, string $draft_hash, string $site_hash ): int { $author = $this->author_for( $job ); // Ohne SEO-Plugin und ohne Einleitung dient die Beschreibung als // Auszug — viele Themes zeigen ihn in Uebersichten. if ( '' === $excerpt && SeoAdapters::active() instanceof NullAdapter ) { $excerpt = $description; } $written = $this->writer->insert( array( 'post_type' => $job->post_type, 'post_status' => 'draft', 'post_title' => $title, 'post_content' => $content, 'post_excerpt' => $excerpt, 'post_author' => $author, 'comment_status' => $job->enable_comments ? 'open' : 'closed', 'ping_status' => 'closed', ) ); $post_id = $written->post_id; // Sofort verknuepfen: Der Server hat den Draft schon als abgeholt // verbucht. Scheitert danach etwas, muss der Beitrag trotzdem zu ihm // gehoeren, sonst entstuende bei der naechsten Lieferung ein Duplikat. DraftMapping::remember( $post_id, $draft_hash, $site_hash, $job->id ); update_post_meta( $post_id, DraftMapping::IMPORTED_TIME, time() ); $this->assign_terms( $job, $post_id, $delivery ); if ( '' !== $description ) { SeoAdapters::active()->write( $post_id, $description ); } $image_url = is_string( $delivery['image_url'] ?? null ) ? trim( $delivery['image_url'] ) : ''; if ( '' !== $image_url ) { try { FeaturedImage::attach( $post_id, $image_url, $title ); } catch ( \Throwable $error ) { // Ein fehlgeschlagener Bild-Download bricht den Import nicht ab. EventLog::add( EventLog::TYPE_IMAGE_FAILED, sprintf( /* translators: %s: error message */ __( 'The featured image could not be loaded, the article was imported without it: %s', 'aivor-content-import' ), $error->getMessage() ), array( 'level' => EventLog::LEVEL_WARNING, 'job_id' => $job->id, 'post_id' => $post_id, ) ); } } $final = $this->final_status( $job ); if ( 'draft' !== $final['post_status'] ) { $this->writer->update( $post_id, $final, $author ); } return $post_id; } /** * Legt eine erweiterte Fassung als Revision an den bestehenden Beitrag. * * Gesucht wird nur mit Gegenprobe der Adresse (PostTypeTarget::find_by_url). * Post-Type und Kategorie des Jobs spielen keine Rolle: Eine Revision * aendert nichts live, und jeder unnoetige Rueckfall erzeugte genau das * Duplikat, das hier vermieden wird. Scheitert etwas, kommt die Fassung als * eigener Entwurf — der Server hat den Draft bereits als abgeholt verbucht, * ein misslungener Abgleich darf den Text nie kosten. * * @param Job $job Der Job. * @param string $url Adresse des bestehenden Artikels. * @param string $content Neuer Body. * @param string $excerpt Neue Einleitung. * @param string $description Neue Meta-Description, gesetzt beim Uebernehmen. * @param string $draft_hash Schluessel des Drafts. * @param string $site_hash Site-Hash. * @return array{post_id: int, note: string} Beitrags-ID 0 und der Protokolltext, wenn es ein eigener Entwurf wird. */ private function extend( Job $job, string $url, string $content, string $excerpt, string $description, string $draft_hash, string $site_hash ): array { $post = PostTypeTarget::find_by_url( $url ); $reason = ''; if ( '' === $draft_hash ) { $reason = __( 'Aivor sent no identifier for it', 'aivor-content-import' ); } elseif ( ! $post instanceof \WP_Post ) { $reason = __( 'no post with exactly this address was found', 'aivor-content-import' ); } elseif ( 'trash' === $post->post_status ) { $reason = __( 'the post is in the trash', 'aivor-content-import' ); } elseif ( ! wp_revisions_enabled( $post ) ) { $reason = __( 'revisions are switched off for this post', 'aivor-content-import' ); } elseif ( ! function_exists( '_wp_put_post_revision' ) ) { $reason = __( 'this WordPress version cannot store the revision', 'aivor-content-import' ); } if ( '' === $reason && $post instanceof \WP_Post ) { try { $written = $this->writer->put_revision( $post, $content, $excerpt, $this->author_for( $job ) ); Extension::remember( $draft_hash, $site_hash, $job->id, $post->ID, $written->post_id, $description ); return array( 'post_id' => $post->ID, 'note' => '', ); } catch ( \RuntimeException $error ) { $reason = sprintf( /* translators: %s: error message */ __( 'the revision could not be saved (%s)', 'aivor-content-import' ), $error->getMessage() ); } } return array( 'post_id' => 0, 'note' => sprintf( /* translators: 1: URL of the existing article, 2: reason */ __( 'Aivor delivered an extended version of %1$s. It was saved as a separate draft because %2$s. The live article was not changed; copy the new text into it if you want to use it.', 'aivor-content-import' ), $url, $reason ), ); } /** * Ob dieselbe erweiterte Fassung schon als Revision wartet. * * Ist die Revision inzwischen verschwunden oder der Beitrag weg bzw. im * Papierkorb, wird die Lieferung neu verarbeitet statt verworfen — sonst * kaeme der Text nirgends an. * * @param Job $job Der Job. * @param string $draft_hash Schluessel des Drafts. * @return bool */ private static function already_waiting( Job $job, string $draft_hash ): bool { $entry = Extension::find( $draft_hash ); if ( null === $entry ) { return false; } $post = get_post( $entry['post_id'] ); if ( ! wp_get_post_revision( $entry['revision_id'] ) instanceof \WP_Post || ! $post instanceof \WP_Post || 'trash' === $post->post_status ) { Extension::forget( $draft_hash ); return false; } EventLog::add( EventLog::TYPE_IMPORTED, __( 'Aivor delivered this extended version again, but it is already waiting as a revision. No second revision was created.', 'aivor-content-import' ), array( 'job_id' => $job->id, 'post_id' => $entry['post_id'], ) ); return true; } /** * Setzt Kategorie bzw. Term des Jobs und die gelieferten Schlagwoerter. * * @param Job $job Der Job. * @param int $post_id Beitrag. * @param array $delivery Lieferung. * @return void */ private function assign_terms( Job $job, int $post_id, array $delivery ): void { if ( '' !== $job->taxonomy && $job->term_id > 0 && is_object_in_taxonomy( $job->post_type, $job->taxonomy ) ) { wp_set_object_terms( $post_id, array( $job->term_id ), $job->taxonomy ); } $tags = is_array( $delivery['tags'] ?? null ) ? $delivery['tags'] : array(); $tags = array_values( array_filter( array_map( static function ( $tag ) { return is_string( $tag ) ? trim( sanitize_text_field( $tag ) ) : ''; }, $tags ) ) ); if ( array() !== $tags && is_object_in_taxonomy( $job->post_type, 'post_tag' ) ) { wp_set_post_tags( $post_id, array_slice( $tags, 0, 20 ), false ); } } /** * Status nach dem Anlegen: Entwurf oder zur Pruefung, nie veroeffentlicht. * * @param Job $job Der Job. * @return array Felder fuer das Update. */ private function final_status( Job $job ): array { return array( 'post_status' => Job::MODE_PENDING === $job->mode ? 'pending' : 'draft' ); } /** * Der Autor eines neuen Beitrags. * * Nur eingetragen, nie als Benutzer angemeldet: der eigene Autor des * Jobs, sonst — auch wenn dessen Benutzer geloescht wurde — der * Standard-Autor. Fehlt beides, bleibt der Beitrag ohne Autor; es wird * bewusst kein Administrator gesucht. * * @param Job $job Der Job. * @return int */ private function author_for( Job $job ): int { if ( $job->author_id > 0 && get_userdata( $job->author_id ) instanceof \WP_User ) { return $job->author_id; } return Settings::default_author_id(); } /** * Klartext zum Ergebnis eines Imports. * * @param int $post_id Beitrag. * @return string */ private static function describe_outcome( int $post_id ): string { if ( 'pending' === get_post_status( $post_id ) ) { return __( 'Imported and submitted for review.', 'aivor-content-import' ); } return __( 'Imported as a draft. Review it and publish it when you are happy with it.', 'aivor-content-import' ); } }