*/ private array $counts = array( 'reported' => 0, 'patched' => 0, 'errors' => 0, ); /** * Baut den Rueckkanal. * * @param AivorClient $client API-Client. * @param PatchApplier $applier Patch-Anwendung. * @param TimeBudget $budget Zeitbudget des Laufs. */ public function __construct( private readonly AivorClient $client, private readonly PatchApplier $applier, private readonly TimeBudget $budget ) {} /** * Meldet alle Beitraege, die inzwischen sichtbar sind oder sich geaendert haben. * * Laeuft vor dem Patch-Abruf, damit inzwischen veroeffentlichte Beitraege * als Linkziele bekannt sind, bevor der Server die Patches berechnet. * * @return void */ public function report_pending(): void { foreach ( DraftMapping::pending_report_ids( self::MAX_REPORTS_PER_RUN ) as $post_id ) { if ( ! $this->may_report() ) { return; } $this->report_published( $post_id, false ); } } /** * Patch-Abruf je Site, mit Heartbeat und nachgeholten Meldungen. * * Patches werden je Site abgerufen, nicht je Job. Jobs ohne bekannten * Site-Hash laufen einzeln; der Server loest die Site dann ueber den Job auf. * * @param Job[] $jobs Alle Jobs. * @return void */ public function process_sites( array $jobs ): void { $held = DraftMapping::held_drafts(); foreach ( self::with_orphaned_sites( self::group_jobs_by_site( $jobs ), $held ) as $group ) { if ( $this->budget->exhausted() ) { return; } try { $this->process_site( $group['job'], $group['site_hash'], self::held_for_group( $held, $group ) ); } catch ( ApiException $error ) { // Eine gestoerte Site darf die uebrigen nicht aufhalten. ++$this->counts['errors']; EventLog::add( EventLog::TYPE_API_ERROR, $error->getMessage(), array( 'level' => EventLog::LEVEL_WARNING, 'job_id' => $group['job'] instanceof Job ? $group['job']->id : 0, ) ); } } } /** * Meldet die Veroeffentlichung eines importierten Beitrags. * * Gemeldet wird nichts, solange der Beitrag nicht oeffentlich ist — ein * Link auf einen noch nicht veroeffentlichten Artikel waere ein toter Link. * Der Server fuehrt solche Drafts als unbestaetigt, bis sie gemeldet sind. * * @param int $post_id Beitrag. * @param bool $force Auch melden, wenn sich seit der letzten Meldung nichts geaendert hat. * @return bool Ob gemeldet wurde. */ public function report_published( int $post_id, bool $force ): bool { $post = get_post( $post_id ); $draft_hash = (string) get_post_meta( $post_id, DraftMapping::DRAFT_HASH, true ); if ( ! $post instanceof \WP_Post || '' === $draft_hash || ! PostTypeTarget::is_visible( $post ) ) { return false; } $url = PostTypeTarget::https_url( $post ); $body = PostWriter::read_body( $post_id ); if ( '' === $url || null === $body ) { // Ohne https lehnt Aivor die Meldung ab; die Notice erklaert das. return false; } $hash = PostWriter::hash( $body ); $already_known = (int) get_post_meta( $post_id, DraftMapping::PUBLISHED_TIME, true ) > 0 && get_post_meta( $post_id, DraftMapping::BODY_HASH, true ) === $hash && get_post_meta( $post_id, DraftMapping::REPORTED_URL, true ) === $url && '' === (string) get_post_meta( $post_id, DraftMapping::NEEDS_REPORT, true ); if ( ! $force && $already_known ) { return false; } $first_report = (int) get_post_meta( $post_id, DraftMapping::PUBLISHED_TIME, true ) <= 0; try { ++$this->reports; // Vorab merken: Scheitert die Meldung, rueckt der Beitrag in der // Rueckstandsliste nach hinten und blockiert keine neueren // (DraftMapping::pending_report_ids()). update_post_meta( $post_id, DraftMapping::REPORT_ATTEMPT, time() ); $result = $this->client->report_published( $draft_hash, $url, $post_id, $hash, $body, Capabilities::all(), (int) get_post_time( 'U', true, $post ) ); } catch ( ApiException $error ) { ++$this->counts['errors']; EventLog::add( EventLog::TYPE_API_ERROR, self::describe_report_error( $error ), array( 'level' => EventLog::LEVEL_WARNING, 'post_id' => $post_id, ) ); return false; } // Ohne Site (klassischer Aivor-Job) markiert Aivor jede Adresse als // nicht zugeordnet. Dort gibt es keine Cluster-Verlinkung; der Befund // waere ein falscher Alarm. $has_site = '' !== (string) get_post_meta( $post_id, DraftMapping::SITE_HASH, true ); $unverified = $has_site && ! empty( $result['url_unverified'] ); DraftMapping::mark_reported( $post_id, $hash, $url, $unverified ); ++$this->counts['reported']; if ( $unverified ) { EventLog::add( EventLog::TYPE_URL_UNVERIFIED, sprintf( /* translators: %s: URL of the post */ __( 'Aivor accepted %s but could not match it to the website stored in Aivor. The article links out, but other articles do not link to it until you confirm the address in Aivor or correct the website address there.', 'aivor-content-import' ), $url ), array( 'level' => EventLog::LEVEL_WARNING, 'post_id' => $post_id, ) ); } elseif ( $first_report ) { EventLog::add( EventLog::TYPE_REPORTED, sprintf( /* translators: %s: URL of the post */ __( 'Reported to Aivor as published: %s', 'aivor-content-import' ), $url ), array( 'post_id' => $post_id ) ); } return true; } /** * Zaehler fuer die Zusammenfassung des Laufs. * * @return array */ public function counts(): array { return $this->counts; } /** * Ein Abruf fuer eine Site. * * @param Job|null $job Stellvertretender Job der Site, null fuer eine Site ohne Job. * @param string $site_hash Site-Hash, leer wenn noch keiner bekannt ist. * @param string[] $held_drafts Gehaltene Drafts dieser Site. * @return void * @throws ApiException Wenn die Site nicht erreichbar ist. */ private function process_site( ?Job $job, string $site_hash, array $held_drafts ): void { $response = $this->client->fetch_patches( $job instanceof Job ? $job->aivor_job_id : '', $site_hash, Capabilities::all(), $held_drafts ); foreach ( $response['unconfirmed_drafts'] as $draft_hash ) { if ( ! $this->may_report() ) { break; } $post_id = DraftMapping::find_post_id( $draft_hash ); if ( $post_id > 0 ) { // Der Server hat keine Adresse fuer diesen Draft — also auch dann // melden, wenn es schon einmal geklappt hat. $this->report_published( $post_id, true ); } } // Sequenziell, jeder gegen den frisch gelesenen Body: Der Server liefert // hoechstens einen Patch je Beitrag und Abruf (K10). foreach ( $response['patches'] as $patch ) { if ( $this->budget->exhausted() ) { // Nicht quittierte Patches liefert der Server erneut. return; } $this->handle_patch( $patch ); } } /** * Wendet einen Patch an, quittiert und protokolliert. * * @param array $patch Patch aus der Lieferung. * @return void */ private function handle_patch( array $patch ): void { $patch_id = (string) ( $patch['patch_id'] ?? '' ); if ( '' === $patch_id ) { return; } $result = $this->applier->apply( $patch ); $this->log_patch( $patch, $result ); if ( ! $result->acknowledge() ) { return; } try { $this->client->report_patch_result( $patch_id, $result->status, $result->hash, $result->body, $result->message ); } catch ( ApiException $error ) { ++$this->counts['errors']; EventLog::add( EventLog::TYPE_API_ERROR, $error->getMessage(), array( 'level' => EventLog::LEVEL_WARNING ) ); return; } if ( PatchResult::APPLIED === $result->status && $result->post_id > 0 ) { // Der Patch hat den Body geaendert und Aivor kennt den neuen Stand // bereits aus der Quittung — kein erneutes published noetig. update_post_meta( $result->post_id, DraftMapping::BODY_HASH, (string) $result->hash ); ++$this->counts['patched']; } } /** * Protokolliert das Ergebnis eines Patches. * * @param array $patch Patch. * @param PatchResult $result Ergebnis. * @return void */ private function log_patch( array $patch, PatchResult $result ): void { $kind = (string) ( $patch['kind'] ?? '' ); switch ( $result->status ) { case PatchResult::APPLIED: if ( ! $result->written ) { return; } $type = EventLog::TYPE_PATCH_APPLIED; $level = EventLog::LEVEL_INFO; $message = 'hub' === $kind ? __( 'Updated the overview of cluster articles in the pillar article.', 'aivor-content-import' ) : __( 'Added an internal link to a related article.', 'aivor-content-import' ); break; case PatchResult::STALE: $type = EventLog::TYPE_PATCH_STALE; $level = EventLog::LEVEL_INFO; $message = __( 'A planned link no longer matches the article, probably because it was edited. Aivor plans the link once more against the current text.', 'aivor-content-import' ); break; case PatchResult::DEFERRED: $type = EventLog::TYPE_PATCH_DEFERRED; $level = EventLog::LEVEL_WARNING; $message = sprintf( /* translators: %s: technical reason */ __( 'A link update was postponed and will be delivered again: %s', 'aivor-content-import' ), (string) $result->message ); break; default: $type = EventLog::TYPE_PATCH_FAILED; $level = EventLog::LEVEL_WARNING; $message = sprintf( /* translators: %s: technical reason */ __( 'A link update could not be applied, nothing was changed: %s', 'aivor-content-import' ), (string) $result->message ); } EventLog::add( $type, $message, array( 'level' => $level, 'post_id' => $result->post_id, ) ); } /** * Ob in diesem Lauf noch gemeldet werden darf. * * @return bool */ private function may_report(): bool { return $this->reports < self::MAX_REPORTS_PER_RUN && ! $this->budget->exhausted(); } /** * Meldung fuer einen abgelehnten published-Callback. * * @param ApiException $error Fehler. * @return string */ private static function describe_report_error( ApiException $error ): string { if ( 409 === $error->status ) { return __( 'Aivor already links this article to another post on this website. Nothing was reported; check for a duplicate post.', 'aivor-content-import' ); } return $error->getMessage(); } /** * Fasst die Jobs je Site zusammen. * * @param Job[] $jobs Alle Jobs. * @return array */ private static function group_jobs_by_site( array $jobs ): array { $groups = array(); foreach ( $jobs as $job ) { if ( ! AivorClient::is_uuid( $job->aivor_job_id ) ) { continue; } $key = AivorClient::is_uuid( $job->site_hash ) ? $job->site_hash : 'job:' . $job->id; if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'job' => $job, 'site_hash' => AivorClient::is_uuid( $job->site_hash ) ? $job->site_hash : '', 'job_ids' => array(), ); } $groups[ $key ]['job_ids'][] = $job->id; } return array_values( $groups ); } /** * Ergaenzt Sites, zu denen es gehaltene Entwuerfe, aber keinen Job mehr gibt. * * Wird ein Job geloescht oder bekommt er eine andere Import-Job-ID, liegen * seine bisherigen Entwuerfe womoeglich noch unveroeffentlicht in * WordPress. Ohne eigenen Abruf fuer ihre Site bekaemen sie keinen * Heartbeat, und Aivor hielte sie nach acht Tagen fuer nie angekommen — * obwohl sie hier liegen (Review 2026-10-20). * * @param array $groups Gruppen aus den Jobs. * @param array $held Gehaltene Drafts. * @return array */ private static function with_orphaned_sites( array $groups, array $held ): array { $known = wp_list_pluck( $groups, 'site_hash' ); foreach ( $held as $item ) { if ( AivorClient::is_uuid( $item['site_hash'] ) && ! in_array( $item['site_hash'], $known, true ) ) { $known[] = $item['site_hash']; $groups[] = array( 'job' => null, 'site_hash' => $item['site_hash'], 'job_ids' => array(), ); } } return $groups; } /** * Die gehaltenen Drafts, die zu einer Site-Gruppe gehoeren. * * @param array $held Alle gehaltenen Drafts. * @param array{job: Job|null, site_hash: string, job_ids: int[]} $group Gruppe. * @return string[] */ private static function held_for_group( array $held, array $group ): array { $hashes = array(); foreach ( $held as $item ) { $matches_site = '' !== $group['site_hash'] && $item['site_hash'] === $group['site_hash']; $matches_job = '' === $item['site_hash'] && in_array( $item['job_id'], $group['job_ids'], true ); if ( $matches_site || $matches_job ) { $hashes[] = $item['draft_hash']; } } return array_slice( $hashes, 0, AivorClient::MAX_HELD_DRAFTS ); } }