0 ) { update_post_meta( $post_id, self::NEEDS_REPORT, 1 ); } } /** * Der Beitrag zu einem Draft, Papierkorb ausgenommen. * * @param string $draft_hash Schluessel des Drafts. * @return int Beitrags-ID, 0 wenn keiner existiert. */ public static function find_post_id( string $draft_hash ): int { if ( '' === $draft_hash ) { return 0; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reverse-Lookup ueber den meta_key-Index, bewusst am Cache vorbei. $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE m.meta_key = %s AND m.meta_value = %s AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' ) ORDER BY p.ID DESC LIMIT 1", self::DRAFT_HASH, $draft_hash ) ); return (int) $post_id; } /** * Loest einen Draft von Beitraegen im Papierkorb. * * Wird ein Draft nach dem Loeschen des Beitrags in Aivor erneut * freigegeben, entsteht ein neuer Beitrag. Der alte im Papierkorb darf den * Schluessel dann nicht weiter tragen. * * @param string $draft_hash Schluessel des Drafts. * @return void */ public static function release_trashed( string $draft_hash ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- siehe find_post_id(). $trashed = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE m.meta_key = %s AND m.meta_value = %s AND p.post_status = 'trash'", self::DRAFT_HASH, $draft_hash ) ); foreach ( $trashed as $post_id ) { delete_post_meta( (int) $post_id, self::DRAFT_HASH ); } } /** * Schreibt die Verknuepfung nach einem Import. * * @param int $post_id Beitrag. * @param string $draft_hash Schluessel des Drafts, leer im Legacy-Pfad. * @param string $site_hash Site-Hash, leer wenn keiner geliefert wurde. * @param int $job_id Lokale Job-ID. * @return void */ public static function remember( int $post_id, string $draft_hash, string $site_hash, int $job_id ): void { if ( '' !== $draft_hash ) { update_post_meta( $post_id, self::DRAFT_HASH, $draft_hash ); update_post_meta( $post_id, self::PUBLISHED_TIME, 0 ); } if ( '' !== $site_hash ) { update_post_meta( $post_id, self::SITE_HASH, $site_hash ); } update_post_meta( $post_id, self::JOB_ID, $job_id ); update_post_meta( $post_id, self::AI_GENERATED, 1 ); update_post_meta( $post_id, self::PUBLISH_MODE, 'review' ); delete_post_meta( $post_id, self::BODY_HASH ); delete_post_meta( $post_id, self::REPORTED_URL ); delete_post_meta( $post_id, self::NEEDS_REPORT ); } /** * Haelt eine erfolgreiche Meldung fest. * * @param int $post_id Beitrag. * @param string $body_hash Gemeldeter Hash. * @param string $url Gemeldete Adresse. * @param bool $unverified Ob Aivor die Adresse nicht zuordnen konnte. * @return void */ public static function mark_reported( int $post_id, string $body_hash, string $url, bool $unverified ): void { update_post_meta( $post_id, self::PUBLISHED_TIME, time() ); update_post_meta( $post_id, self::BODY_HASH, $body_hash ); update_post_meta( $post_id, self::REPORTED_URL, $url ); delete_post_meta( $post_id, self::NEEDS_REPORT ); delete_post_meta( $post_id, self::REPORT_ATTEMPT ); if ( $unverified ) { update_post_meta( $post_id, self::URL_UNVERIFIED, 1 ); } else { delete_post_meta( $post_id, self::URL_UNVERIFIED ); } } /** * Oeffentliche Beitraege, deren Meldung aussteht oder neu faellig ist. * * Nur, was ueberhaupt meldbar ist: veroeffentlicht, ohne Passwort, in einem * oeffentlichen Post-Type. Zuerst kommt, was noch nie versucht wurde, dann * der am laengsten zurueckliegende Versuch — ein dauerhaft scheiternder * Beitrag (etwa 409, weil Aivor ihn einem Duplikat zuordnet) blockiert so * keine neueren (Review 2026-10-20). * * @param int $limit Hoechstzahl. * @return int[] Beitrags-IDs. */ public static function pending_report_ids( int $limit ): array { global $wpdb; $types = array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) ); if ( array() === $types ) { return array(); } $placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Rueckstaende des Rueckkanals; die Platzhalterliste entsteht aus den registrierten Post-Types. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} d ON d.post_id = p.ID AND d.meta_key = %s LEFT JOIN {$wpdb->postmeta} t ON t.post_id = p.ID AND t.meta_key = %s LEFT JOIN {$wpdb->postmeta} r ON r.post_id = p.ID AND r.meta_key = %s LEFT JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = %s WHERE p.post_status = 'publish' AND p.post_password = '' AND p.post_type IN ( {$placeholders} ) AND ( t.meta_value IS NULL OR t.meta_value = '0' OR r.meta_value = '1' ) GROUP BY p.ID ORDER BY MIN( COALESCE( CAST( a.meta_value AS UNSIGNED ), 0 ) ) ASC, p.ID ASC LIMIT %d", array_merge( array( self::DRAFT_HASH, self::PUBLISHED_TIME, self::NEEDS_REPORT, self::REPORT_ATTEMPT ), $types, array( $limit ) ) ) ); // phpcs:enable return array_map( 'intval', $ids ); } /** * Importierte, noch nie gemeldete Beitraege, die nicht oeffentlich sind, * dazu jede nicht uebernommene erweiterte Fassung. * * Die Entwuerfe stehen vorn: Der Heartbeat je Site ist gedeckelt * (AivorClient::MAX_HELD_DRAFTS), und ein herausgefallener Pillar-Entwurf * liesse den Cluster nach acht Tagen ohne ihn weiterlaufen. Eine * herausgefallene Fassung kostet nur die Mail „nie angekommen". * * @return array */ public static function held_drafts(): array { global $wpdb; $statuses = self::HELD_STATUSES; $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Heartbeat fuer Aivor; die Platzhalterliste entsteht aus einer Konstante. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT d.meta_value AS draft_hash, s.meta_value AS site_hash, j.meta_value AS job_id FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} d ON d.post_id = p.ID AND d.meta_key = %s LEFT JOIN {$wpdb->postmeta} t ON t.post_id = p.ID AND t.meta_key = %s LEFT JOIN {$wpdb->postmeta} s ON s.post_id = p.ID AND s.meta_key = %s LEFT JOIN {$wpdb->postmeta} j ON j.post_id = p.ID AND j.meta_key = %s WHERE p.post_status IN ( {$placeholders} ) AND ( t.meta_value IS NULL OR t.meta_value = '0' ) ORDER BY p.ID DESC LIMIT 500", array_merge( array( self::DRAFT_HASH, self::PUBLISHED_TIME, self::SITE_HASH, self::JOB_ID ), $statuses ) ), ARRAY_A ); // phpcs:enable $held = array(); foreach ( (array) $rows as $row ) { $held[] = array( 'draft_hash' => (string) $row['draft_hash'], 'site_hash' => (string) ( $row['site_hash'] ?? '' ), 'job_id' => (int) ( $row['job_id'] ?? 0 ), ); } return array_merge( $held, Extension::held() ); } /** * Wie viele neue Beitraege seit dem Zeitpunkt importiert wurden. * * Gezaehlt wird an den Beitraegen selbst, auch im Papierkorb: Ein Import * hat stattgefunden, auch wenn der Beitrag danach geloescht wurde. * * @param int $since Unix-Zeitstempel. * @return int */ public static function count_imported_since( int $since ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Zaehlung fuer die Wochengrenze. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s AND CAST( meta_value AS UNSIGNED ) >= %d", self::IMPORTED_TIME, $since ) ); } /** * Der aelteste Import im Zeitfenster — bestimmt, wann wieder Platz ist. * * @param int $since Unix-Zeitstempel. * @return int Zeitstempel, 0 wenn es keinen gibt. */ public static function oldest_import_since( int $since ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Zaehlung fuer die Wochengrenze. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT MIN( CAST( meta_value AS UNSIGNED ) ) FROM {$wpdb->postmeta} WHERE meta_key = %s AND CAST( meta_value AS UNSIGNED ) >= %d", self::IMPORTED_TIME, $since ) ); } /** * Ob Aivor mindestens eine gemeldete Adresse nicht bestaetigt hat. * * @return bool */ public static function has_unverified_url(): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin-Hinweis. return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT m.post_id FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id INNER JOIN {$wpdb->postmeta} s ON s.post_id = m.post_id AND s.meta_key = %s AND s.meta_value <> '' WHERE m.meta_key = %s AND p.post_status = 'publish' LIMIT 1", self::SITE_HASH, self::URL_UNVERIFIED ) ); } }