(Kopie)" as draft of the current user. All or nothing: if meta or * terms fail after the insert, the new draft is deleted again. * * @throws WriteFailed */ public function duplicate(WP_Post $original): int { return $this->cloneAsDraft($original, self::COPY_SUFFIX, self::COPY_OF_META, 'Die Kopie'); } /** * Creates " (Sicherung)" – a full clone of the post – and moves it to the trash. * If either step fails, the backup is deleted again. Call only when the trash is enabled. * * @throws WriteFailed */ public function backup(WP_Post $original): int { $backupId = $this->cloneAsDraft($original, self::BACKUP_SUFFIX, self::BACKUP_OF_META, 'Die Sicherung'); try { $trashed = wp_trash_post($backupId); if (! $trashed || get_post_status($backupId) !== 'trash') { throw new RuntimeException('Backup could not be moved to the trash.'); } } catch (Throwable $e) { $this->discard($original, $backupId, $e, 'Die Sicherung konnte nicht in den Papierkorb gelegt werden.'); } return $backupId; } /** * Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy * into the original. Status, slug, date and author of the original stay. Everything is * read before the first write, so a failed read leaves the original untouched. * * @throws ReplaceFailed Naming the parts that were (possibly) written. */ public function replace(WP_Post $original, WP_Post $copy): void { $originalId = (int) $original->ID; $parts = []; try { $meta = $this->ownMeta((int) $copy->ID); $terms = $this->termIds($copy); $this->assertNoMissedSchedule($originalId); $parts[] = 'post'; $this->updateRow($originalId, [ 'post_title' => self::withoutSuffix((string) $copy->post_title), 'post_content' => (string) $copy->post_content, 'post_excerpt' => (string) $copy->post_excerpt, ]); $parts[] = 'meta'; $this->replaceMeta($originalId, $meta); $parts[] = 'terms'; $this->setTerms($originalId, $terms); } catch (Throwable $e) { throw new ReplaceFailed($parts, $e); } } /** * Everything replace() overwrites – call before replace(). * * @return array{post: array<string, string>, meta: array<string, list<?string>>, terms: array<string, list<int>>} * @throws RuntimeException */ public function snapshot(WP_Post $post): array { return [ 'post' => [ 'post_title' => (string) $post->post_title, 'post_content' => (string) $post->post_content, 'post_excerpt' => (string) $post->post_excerpt, ], 'meta' => $this->ownMeta((int) $post->ID), 'terms' => $this->termIds($post), ]; } /** * Writes the given parts of a snapshot back. Tries every part even if one fails. * * @param array{post: array<string, string>, meta: array<string, list<?string>>, terms: array<string, list<int>>} * $snapshot Output of snapshot(). * @param list<string> $parts Subset of PARTS. * @throws RuntimeException Listing every part that could not be restored. */ public function restore(WP_Post $post, array $snapshot, array $parts = self::PARTS): void { $postId = (int) $post->ID; $steps = [ 'post' => fn () => $this->updateRow($postId, $snapshot['post']), 'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']), 'terms' => fn () => $this->setTerms($postId, $snapshot['terms']), ]; $failures = []; foreach (array_intersect_key($steps, array_flip($parts)) as $step) { try { $step(); } catch (Throwable $e) { $failures[] = $e->getMessage(); } } if ($failures !== []) { throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures)); } } public static function withoutSuffix(string $title): string { return (string) preg_replace('/ \(Kopie\)$/u', '', $title); } /** @throws WriteFailed */ private function cloneAsDraft(WP_Post $original, string $suffix, string $markerKey, string $label): int { $originalId = (int) $original->ID; $cloneId = wp_insert_post(wp_slash([ 'post_type' => (string) $original->post_type, 'post_status' => 'draft', 'post_title' => (string) $original->post_title . $suffix, 'post_content' => (string) $original->post_content, 'post_excerpt' => (string) $original->post_excerpt, 'post_parent' => (int) $original->post_parent, 'menu_order' => (int) $original->menu_order, 'comment_status' => (string) $original->comment_status, 'ping_status' => (string) $original->ping_status, 'post_password' => (string) $original->post_password, 'post_author' => get_current_user_id(), ]), true); if ($cloneId instanceof WP_Error || (int) $cloneId === 0) { $reason = $cloneId instanceof WP_Error ? $cloneId->get_error_message() : 'unknown error'; throw new WriteFailed( $label . ' konnte nicht angelegt werden.', true, null, null, null, sprintf('Clone (%s) of post %d could not be created: %s', $markerKey, $originalId, $reason) ); } $cloneId = (int) $cloneId; try { $this->replaceMeta($cloneId, $this->ownMeta($originalId)); $this->setTerms($cloneId, $this->termIds($original)); if (update_post_meta($cloneId, $markerKey, $originalId) === false) { throw new RuntimeException('Clone marker could not be written.'); } } catch (Throwable $e) { $this->discard($original, $cloneId, $e, $label . ' konnte nicht vollständig angelegt werden.'); } return $cloneId; } /** * Deletes a clone this class just created (never anything else) and reports the failure. * * @throws WriteFailed Always. */ private function discard(WP_Post $original, int $cloneId, Throwable $cause, string $reason): never { $deleted = wp_delete_post($cloneId, true); $detail = sprintf('Clone %d of post %d failed: %s', $cloneId, (int) $original->ID, $cause->getMessage()); if ($deleted) { throw new WriteFailed($reason, true, null, null, $cause, $detail . '; clone deleted'); } throw new WriteFailed($reason, false, $cloneId, null, $cause, $detail . '; clone could not be deleted'); } /** * @param array<string, string> $columns * @throws RuntimeException */ private function updateRow(int $postId, array $columns): void { $this->assertNoMissedSchedule($postId); $result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true); if ($result instanceof WP_Error || ! $result) { $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; throw new RuntimeException(sprintf('Post %d could not be updated: %s', $postId, $message)); } } /** * wp_update_post() publishes a scheduled post whose date has passed – never rewrite such a row. * * @throws RuntimeException */ private function assertNoMissedSchedule(int $postId): void { $post = get_post($postId); if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) { throw new RuntimeException(sprintf( 'Post %d not written: saving it now would publish a missed schedule.', $postId )); } } /** * Stored meta of a post without SKIP_META: meta_value strings exactly as in the database. * A failed query is an error – treating it as "no meta" would wipe meta on replace(). * * @return array<string, list<string|null>> * @throws RuntimeException */ private function ownMeta(int $postId): array { $db = $this->db(); $db->last_error = ''; $rows = $db->get_results( $db->prepare( "SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id", $postId ), 'ARRAY_A' ); if (! is_array($rows) || (string) $db->last_error !== '') { throw new RuntimeException(sprintf( 'Meta of post %d could not be read: %s', $postId, (string) $db->last_error !== '' ? (string) $db->last_error : 'no result' )); } $meta = []; foreach ($rows as $row) { $key = (string) $row['meta_key']; if (! in_array($key, self::SKIP_META, true)) { $meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value']; } } return $meta; } /** * Makes the post's meta (except SKIP_META) exactly $meta; values are inserted as stored. * * @param array<string, list<string|null>> $meta * @throws RuntimeException */ private function replaceMeta(int $postId, array $meta): void { $db = $this->db(); try { foreach (array_keys($this->ownMeta($postId)) as $key) { if (delete_post_meta($postId, $key) === false) { throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId)); } } foreach ($meta as $key => $values) { foreach ($values as $value) { $inserted = $db->insert( $db->postmeta, ['post_id' => $postId, 'meta_key' => $key, 'meta_value' => $value], ['%d', '%s', '%s'] ); if ($inserted === false) { throw new RuntimeException( sprintf('Meta "%s" of post %d could not be written.', $key, $postId) ); } } } } finally { wp_cache_delete($postId, 'post_meta'); } } /** * @return array<string, list<int>> Taxonomy => term IDs, for every taxonomy of the post type. * @throws RuntimeException */ private function termIds(WP_Post $post): array { $terms = []; foreach (get_object_taxonomies((string) $post->post_type) as $taxonomy) { $ids = wp_get_object_terms((int) $post->ID, $taxonomy, ['fields' => 'ids']); if ($ids instanceof WP_Error) { throw new RuntimeException( sprintf('Terms (%s) could not be read: %s', $taxonomy, $ids->get_error_message()) ); } $terms[(string) $taxonomy] = array_map('intval', (array) $ids); } return $terms; } /** * @param array<string, list<int>> $terms * @throws RuntimeException */ private function setTerms(int $postId, array $terms): void { foreach ($terms as $taxonomy => $ids) { $result = wp_set_object_terms($postId, $ids, $taxonomy, false); if ($result instanceof WP_Error) { throw new RuntimeException( sprintf('Terms (%s) could not be set: %s', $taxonomy, $result->get_error_message()) ); } } } private function db(): \wpdb { if ($this->db === null) { global $wpdb; $this->db = $wpdb; } return $this->db; } }