*/ public static function all(): array { $entries = get_option( self::OPTION, array() ); if ( ! is_array( $entries ) ) { return array(); } $clean = array(); foreach ( $entries as $entry ) { if ( ! is_array( $entry ) || empty( $entry['draft_hash'] ) ) { continue; } $clean[] = array( 'draft_hash' => (string) $entry['draft_hash'], 'site_hash' => (string) ( $entry['site_hash'] ?? '' ), 'job_id' => (int) ( $entry['job_id'] ?? 0 ), 'post_id' => (int) ( $entry['post_id'] ?? 0 ), 'revision_id' => (int) ( $entry['revision_id'] ?? 0 ), 'description' => (string) ( $entry['description'] ?? '' ), 'state' => self::STATE_DISMISSED === ( $entry['state'] ?? '' ) ? self::STATE_DISMISSED : self::STATE_WAITING, 'time' => (int) ( $entry['time'] ?? 0 ), ); } return $clean; } /** * Die gemerkte Fassung zu einem Draft. * * @param string $draft_hash Schluessel des Drafts. * @return array{draft_hash: string, site_hash: string, job_id: int, post_id: int, revision_id: int, description: string, state: string, time: int}|null */ public static function find( string $draft_hash ): ?array { foreach ( self::all() as $entry ) { if ( $entry['draft_hash'] === $draft_hash ) { return $entry; } } return null; } /** * Die wartende Fassung eines Beitrags, deren Revision noch existiert. * * Ohne Revision gibt es nichts zu vergleichen: kein Hinweis, kein Etikett. * Gehalten wird der Draft trotzdem weiter (held()). * * @param int $post_id Beitrag. * @return array{draft_hash: string, site_hash: string, job_id: int, post_id: int, revision_id: int, description: string, state: string, time: int}|null */ public static function comparable_for( int $post_id ): ?array { $entry = self::waiting_for( $post_id ); if ( null === $entry ) { return null; } $revision_id = $entry['revision_id']; return wp_get_post_revision( $revision_id ) instanceof \WP_Post ? $entry : null; } /** * Die wartende Fassung eines Beitrags, falls es eine gibt. * * @param int $post_id Beitrag. * @return array{draft_hash: string, site_hash: string, job_id: int, post_id: int, revision_id: int, description: string, state: string, time: int}|null */ public static function waiting_for( int $post_id ): ?array { foreach ( self::all() as $entry ) { if ( $entry['post_id'] === $post_id && self::STATE_WAITING === $entry['state'] ) { return $entry; } } return null; } /** * Merkt eine neu angelegte Fassung. * * Eine aeltere wartende Fassung desselben Beitrags gilt danach als * ausgeblendet: Am Beitrag steht immer nur der Hinweis zur neuesten. * * @param string $draft_hash Schluessel des Drafts. * @param string $site_hash Site-Hash. * @param int $job_id Lokale Job-ID. * @param int $post_id Erweiterter Beitrag. * @param int $revision_id Angelegte Revision. * @param string $description Meta-Description der Fassung. * @return void */ public static function remember( string $draft_hash, string $site_hash, int $job_id, int $post_id, int $revision_id, string $description ): void { $entries = array(); foreach ( self::fresh() as $entry ) { if ( $entry['draft_hash'] === $draft_hash ) { continue; } if ( $entry['post_id'] === $post_id ) { $entry['state'] = self::STATE_DISMISSED; } $entries[] = $entry; } array_unshift( $entries, array( 'draft_hash' => $draft_hash, 'site_hash' => $site_hash, 'job_id' => $job_id, 'post_id' => $post_id, 'revision_id' => $revision_id, 'description' => $description, 'state' => self::STATE_WAITING, 'time' => time(), ) ); self::store( $entries ); } /** * Blendet den Hinweis zu einer Fassung aus. * * @param string $draft_hash Schluessel des Drafts. * @return void */ public static function dismiss( string $draft_hash ): void { $entries = self::fresh(); foreach ( $entries as $index => $entry ) { if ( $entry['draft_hash'] === $draft_hash ) { $entries[ $index ]['state'] = self::STATE_DISMISSED; } } self::store( $entries ); } /** * Vergisst eine Fassung. * * @param string $draft_hash Schluessel des Drafts. * @return void */ public static function forget( string $draft_hash ): void { $entries = array_values( array_filter( self::fresh(), static function ( array $entry ) use ( $draft_hash ): bool { return $entry['draft_hash'] !== $draft_hash; } ) ); self::store( $entries ); } /** * Vergisst alle Fassungen eines endgueltig geloeschten Beitrags. * * Ohne Beitrag gibt es nichts mehr zu uebernehmen. Aivor erfaehrt das * ueber den ausbleibenden Heartbeat und meldet den Artikel nach acht Tagen * als nicht angekommen — fuer einen geloeschten Artikel der richtige Hinweis. * Fuer geloeschte Revisionen (eigene ID) ist das ein No-op. * * @param int $post_id Geloeschter Beitrag. * @return void */ public static function forget_post( $post_id ): void { $post_id = (int) $post_id; $entries = self::fresh(); $keep = array_values( array_filter( $entries, static function ( array $entry ) use ( $post_id ): bool { return $entry['post_id'] !== $post_id; } ) ); if ( count( $keep ) !== count( $entries ) ) { self::store( $keep ); } } /** * Fassungen fuer den Heartbeat, im Format von DraftMapping::held_drafts(). * * Nur solche, deren Beitrag noch existiert — auch wenn er geloescht wurde, * waehrend das Plugin inaktiv war und forget_post() nicht lief. * * @return array */ public static function held(): array { $held = array(); foreach ( self::all() as $entry ) { if ( ! get_post( $entry['post_id'] ) instanceof \WP_Post ) { continue; } $held[] = array( 'draft_hash' => $entry['draft_hash'], 'site_hash' => $entry['site_hash'], 'job_id' => $entry['job_id'], ); } return $held; } /** * Erkennt, dass ein Mensch eine Fassung uebernommen hat. * * Am Inhalt, nicht am Hook wp_restore_post_revision: WordPress 7.1 stellt * im Block-Editor per REST wieder her, dort feuert der Hook nie. Gleicher * Inhalt deckt das klassische Revisionsfenster, die visuellen Revisionen, * komplettes Hineinkopieren und WP-CLI ab. Verglichen wird wie im Core * (normalize_whitespace), beide Seiten frisch aus der Datenbank. * * @param int $post_id Beitrag. * @param \WP_Post $post Beitrag. * @param bool $update Ob ein bestehender Beitrag geaendert wurde. * @return void */ public static function detect_applied( $post_id, $post, $update ): void { $post_id = (int) $post_id; if ( ! $update || PostWriter::is_writing() || wp_is_post_revision( $post_id ) ) { return; } $candidates = array_filter( self::all(), static function ( array $entry ) use ( $post_id ): bool { return $entry['post_id'] === $post_id; } ); if ( array() === $candidates ) { return; } $body = PostWriter::read_body( $post_id ); if ( null === $body ) { return; } foreach ( $candidates as $entry ) { $revision_body = $entry['revision_id'] > 0 ? PostWriter::read_body( $entry['revision_id'] ) : null; if ( null !== $revision_body && normalize_whitespace( $revision_body ) === normalize_whitespace( $body ) ) { self::apply( $entry ); return; } } } /** * Haelt wartende Aivor-Revisionen aus dem Aufraeumen nach dem Revisionslimit heraus. * * @param \WP_Post[] $revisions Kandidaten, aelteste zuerst. * @param int $post_id Beitrag. * @return \WP_Post[] */ public static function protect_waiting( $revisions, $post_id ): array { $revisions = is_array( $revisions ) ? $revisions : array(); $entry = self::waiting_for( (int) $post_id ); if ( null === $entry ) { return $revisions; } return array_values( array_filter( $revisions, static function ( $revision ) use ( $entry ): bool { return ! ( $revision instanceof \WP_Post && $revision->ID === $entry['revision_id'] ); } ) ); } /** * Stellt den Beitrag auf den neuen Draft um. * * Der Beitrag traegt danach die Verknuepfung des neuen Drafts; eine alte * wird ueberschrieben, nicht verdoppelt — sonst pruefen Folge-Patches gegen * den falschen Hash (technisches Konzept 8). Gemeldet wird ueber den * Rueckstand im naechsten Lauf, sobald der Beitrag oeffentlich ist. * * @param array{draft_hash: string, site_hash: string, job_id: int, post_id: int, revision_id: int, description: string, state: string, time: int} $entry Fassung. * @return void */ private static function apply( array $entry ): void { DraftMapping::remember( $entry['post_id'], $entry['draft_hash'], $entry['site_hash'], $entry['job_id'] ); if ( '' !== $entry['description'] ) { SeoAdapters::active()->write( $entry['post_id'], $entry['description'] ); } self::forget( $entry['draft_hash'] ); EventLog::add( EventLog::TYPE_REPLACED, __( 'The extended version from Aivor was applied to this article. Title and address stayed the same. Aivor is informed with the next run.', 'aivor-content-import' ), array( 'job_id' => $entry['job_id'], 'post_id' => $entry['post_id'], ) ); } /** * Frisch gelesen, siehe Settings::update(): Ein laufender Import und ein * Redakteur koennen gleichzeitig schreiben. * * @return array */ private static function fresh(): array { wp_cache_delete( self::OPTION, 'options' ); return self::all(); } /** * Speichert gedeckelt: Fallen Eintraege heraus, dann zuerst ausgeblendete, aelteste zuerst. * * @param array> $entries Eintraege, neueste zuerst. * @return void */ private static function store( array $entries ): void { $excess = count( $entries ) - self::MAX_ENTRIES; for ( $i = 0; $i < $excess; $i++ ) { $drop = null; foreach ( $entries as $index => $entry ) { if ( self::STATE_DISMISSED === $entry['state'] ) { $drop = $index; } } unset( $entries[ $drop ?? array_key_last( $entries ) ] ); $entries = array_values( $entries ); } if ( array() === $entries ) { delete_option( self::OPTION ); return; } update_option( self::OPTION, $entries, false ); } }