Wp2ghost/WPPlugin/rbrExportRestApi.php
Roland 24d88e224b Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
  abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
  Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert

Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
  zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px

Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00

429 lines
No EOL
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Plugin Name: RBR Export to Ghost
* Plugin URI:
* Description: Exportiert WordPress-Beiträge und Medien über eine REST-API für den Import in Ghost CMS.
* Version: 1.0.0
* Author: Roland Brunner
* Author URI:
* License: GPL-2.0+
* License URI: https://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: rbr-export-ghost
* Domain Path: /languages
*/
if (!defined('ABSPATH')) {
exit;
}
define('RBR_EXPORT_VERSION', '1.0.0');
define('RBR_EXPORT_PATH', plugin_dir_path(__FILE__));
define('RBR_EXPORT_URL', plugin_dir_url(__FILE__));
register_activation_hook(__FILE__, 'rbr_export_activate');
register_deactivation_hook(__FILE__, 'rbr_export_deactivate');
function rbr_export_activate() {
flush_rewrite_rules();
}
function rbr_export_deactivate() {
flush_rewrite_rules();
}
/**
* Sanitisiert den per_page-Wert und bewahrt -1 für "alle".
*/
function rbr_sanitize_per_page($value) {
return (int) $value;
}
add_action('rest_api_init', function () {
// 1. Endpunkt: Alle Post-IDs abrufen
register_rest_route('custom/v1', '/posts-ids', array(
'methods' => 'GET',
'callback' => 'rbr_get_all_post_ids',
'permission_callback' => '__return_true',
'args' => array(
'post_type' => array(
'default' => 'post',
'sanitize_callback' => 'sanitize_text_field',
),
'per_page' => array(
'default' => -1,
'sanitize_callback' => 'rbr_sanitize_per_page',
),
),
));
// 2. Endpunkt: Content & Medien für eine bestimmte Post-ID
register_rest_route('custom/v1', '/post-details/(?P<id>\d+)', array(
'methods' => 'GET',
'callback' => 'rbr_get_post_details_with_media',
'permission_callback' => '__return_true',
));
// 3. Endpunkt: Kategorien/Tags für alle Beiträge
register_rest_route('custom/v1', '/post-taxonomies/(?P<id>\d+)', array(
'methods' => 'GET',
'callback' => 'rbr_get_post_taxonomies',
'permission_callback' => '__return_true',
));
// 4. Endpunkt: Alle Medien-Dateien (Attachments) abrufen
register_rest_route('custom/v1', '/media', array(
'methods' => 'GET',
'callback' => 'rbr_get_all_media',
'permission_callback' => '__return_true',
'args' => array(
'per_page' => array(
'default' => -1,
'sanitize_callback' => 'rbr_sanitize_per_page',
),
),
));
// 5. Endpunkt: Medien-Datei direkt über ID abrufen
register_rest_route('custom/v1', '/media/(?P<id>\d+)', array(
'methods' => 'GET',
'callback' => 'rbr_get_media_by_id',
'permission_callback' => '__return_true',
));
});
/**
* Liefert ein Array mit allen veröffentlichten Post-IDs.
* Unterstützt verschiedene Post-Types (post, page, custom).
*/
function rbr_get_all_post_ids($request) {
$post_type = sanitize_text_field($request['post_type']);
$per_page = rbr_sanitize_per_page($request['per_page']);
$args = array(
'post_type' => $post_type,
'post_status' => 'publish',
'numberposts' => $per_page,
'fields' => 'ids',
'orderby' => 'date',
'order' => 'ASC',
);
$post_ids = get_posts($args);
if (empty($post_ids)) {
return new WP_Error('no_posts', 'Keine Beiträge gefunden.', array('status' => 404));
}
return rest_ensure_response(array(
'count' => count($post_ids),
'post_type' => $post_type,
'ids' => $post_ids,
));
}
/**
* Liefert Content, Featured Image, Kategorien, Keywords, Comments und alle im HTML/Post genutzten Medien-URLs.
*/
function rbr_get_post_details_with_media($data) {
$post_id = intval($data['id']);
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') {
return new WP_Error('no_post', 'Beitrag nicht gefunden.', array('status' => 404));
}
$content = apply_filters('the_content', $post->post_content);
$media_urls = array();
// 1. Featured Image (Titelbild) auslesen
$featured_image_url = '';
$featured_image_id = get_post_thumbnail_id($post_id);
if ($featured_image_id) {
$featured_image_url = wp_get_attachment_url($featured_image_id);
if ($featured_image_url) {
$media_urls[] = $featured_image_url;
}
}
// 2. Inhaltsbilder aus dem HTML extrahieren (via Regex für <img> Tags)
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $post->post_content, $matches);
if (!empty($matches[1])) {
foreach ($matches[1] as $img_url) {
if (filter_var($img_url, FILTER_VALIDATE_URL)) {
$media_urls[] = $img_url;
}
}
}
// 3. Attachments die mit dem Post verknüpft sind
$attachments = get_posts(array(
'post_type' => 'attachment',
'numberposts' => -1,
'post_status' => null,
'post_parent' => $post_id,
'fields' => 'ids',
'post_mime_type' => 'image',
));
foreach ($attachments as $att_id) {
$att_url = wp_get_attachment_url($att_id);
if ($att_url) {
$media_urls[] = $att_url;
}
}
// Duplikate entfernen
$unique_media = array_values(array_unique($media_urls));
// 4. Kategorien - bestimme das Primäre (erste Kategorie oder nach slug sortiert)
$categories = array();
$primary_category = null;
$cats = get_the_category($post_id);
if (!empty($cats) && !is_wp_error($cats)) {
// Sortiere nach term_id (älteste/erste Kategorie wird primär)
usort($cats, function($a, $b) {
return $a->term_id <=> $b->term_id;
});
foreach ($cats as $cat) {
$is_primary = ($cat->term_id === $cats[0]->term_id);
$categories[] = array(
'id' => intval($cat->term_id),
'name' => $cat->name,
'slug' => $cat->slug,
'primary' => $is_primary,
);
if ($is_primary) {
$primary_category = $cat->name;
}
}
}
// 5. Tags (post_tag Taxonomy)
$tags = array();
$post_tags = get_the_tags($post_id);
if (!empty($post_tags) && !is_wp_error($post_tags)) {
foreach ($post_tags as $tag) {
$tags[] = array(
'id' => intval($tag->term_id),
'name' => $tag->name,
'slug' => $tag->slug,
);
}
}
// 6. Keywords (aus Yoast SEO oder Rank Math oder Custom Field)
$keywords = '';
// Yoast SEO: Prüfe auf Meta-Key direkt (funktioniert in allen Versionen)
$yoast_kw = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
if (!empty($yoast_kw)) {
$keywords = $yoast_kw;
}
// Rank Math
if (empty($keywords) && function_exists('rank_math_get_single_post_data')) {
$rank_data = rank_math_get_single_post_data($post_id);
if (method_exists($rank_data, 'get_keywords')) {
$kw = $rank_data->get_keywords();
if (!empty($kw)) {
$keywords = $kw;
}
}
}
// Fallback: Custom Field 'post_keywords'
if (empty($keywords)) {
$keywords = get_post_meta($post_id, 'post_keywords', true);
}
// Fallback: Tags als Keywords
if (empty($keywords) && !empty($tags)) {
$keywords = implode(', ', array_column($tags, 'name'));
}
// Fallback: Kategorien als Keywords
if (empty($keywords) && !empty($categories)) {
$keywords = implode(', ', array_column($categories, 'name'));
}
// 6. Kommentare
$comments = array();
$wp_comments = get_comments(array(
'post_id' => $post_id,
'status' => 'approve',
'number' => 100,
));
foreach ($wp_comments as $comment) {
$comments[] = array(
'id' => intval($comment->comment_ID),
'author_name' => $comment->comment_author,
'author_email' => $comment->comment_author_email,
'author_url' => $comment->comment_author_url,
'content' => $comment->comment_content,
'date' => mysql_to_rfc3339($comment->comment_date),
);
}
$response = array(
'id' => intval($post->ID),
'title' => $post->post_title,
'slug' => $post->post_name,
'date' => mysql_to_rfc3339($post->post_date),
'content' => $content,
'excerpt' => wp_trim_words(strip_tags($post->post_excerpt), 55),
'author' => array(
'id' => intval($post->post_author),
'name' => get_the_author_meta('display_name', $post->post_author),
),
'featured_image' => $featured_image_url,
'categories' => $categories,
'primary_category' => $primary_category,
'tags' => $tags,
'keywords' => $keywords,
'comments' => $comments,
'media_files' => $unique_media,
);
return rest_ensure_response($response);
}
/**
* Liefert Kategorien und Tags für einen bestimmten Beitrag.
*/
function rbr_get_post_taxonomies($data) {
$post_id = intval($data['id']);
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') {
return new WP_Error('no_post', 'Beitrag nicht gefunden.', array('status' => 404));
}
$taxonomies = get_object_taxonomies('post');
$terms = array();
foreach ($taxonomies as $taxonomy) {
$terms_list = get_the_terms($post_id, $taxonomy);
if (!empty($terms_list) && !is_wp_error($terms_list)) {
foreach ($terms_list as $term) {
$terms[] = array(
'id' => intval($term->term_id),
'name' => $term->name,
'slug' => $term->slug,
'type' => $taxonomy,
);
}
}
}
return rest_ensure_response(array(
'post_id' => $post_id,
'taxonomies' => $terms,
));
}
/**
* Liefert alle Medien-Dateien (Attachments) der WordPress-Instanz.
*/
function rbr_get_all_media($request) {
$per_page = rbr_sanitize_per_page($request['per_page']);
$args = array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'numberposts' => $per_page,
'orderby' => 'date',
'order' => 'ASC',
'post_mime_type' => array('image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'),
);
$attachments = get_posts($args);
$media_list = array();
foreach ($attachments as $att) {
$media_list[] = array(
'id' => intval($att->ID),
'url' => wp_get_attachment_url($att->ID),
'title' => $att->post_title,
'alt' => get_post_meta($att->ID, '_wp_attachment_image_alt', true),
'mime' => $att->post_mime_type,
'date' => mysql_to_rfc3339($att->post_date),
);
}
return rest_ensure_response(array(
'count' => count($media_list),
'media' => $media_list,
));
}
/**
* Liefert eine einzelne Medien-Datei anhand ihrer ID.
*/
function rbr_get_media_by_id($data) {
$media_id = intval($data['id']);
$attachment = get_post($media_id);
if (!$attachment || $attachment->post_type !== 'attachment') {
return new WP_Error('no_media', 'Medien-Datei nicht gefunden.', array('status' => 404));
}
return rest_ensure_response(array(
'id' => intval($attachment->ID),
'url' => wp_get_attachment_url($attachment->ID),
'title' => $attachment->post_title,
'alt' => get_post_meta($attachment->ID, '_wp_attachment_image_alt', true),
'mime' => $attachment->post_mime_type,
'date' => mysql_to_rfc3339($attachment->post_date),
));
}
/**
* Shortcode: [rbr_export] – Zeigt eine Export-Statistik im Frontend an.
*/
function rbr_export_shortcode($atts) {
$atts = shortcode_atts(array(
'post_type' => 'post',
), $atts, 'rbr_export');
$count = wp_count_posts($atts['post_type']);
$published = $count->publish ?? 0;
return sprintf(
'<div class="rbr-export-info"><p>Es gibt <strong>%d</strong> veröffentlichte %s-Beiträge, die über die REST-API exportiert werden können.</p></div>',
intval($published),
esc_html($atts['post_type'])
);
}
add_shortcode('rbr_export', 'rbr_export_shortcode');
/**
* Admin-Notiz: Plugin ist aktiv und bereit für den Export.
*/
add_action('admin_notices', function () {
if (current_user_can('manage_options')) {
echo '<div class="notice notice-info is-dismissible"><p>';
printf(
__('RBR Export to Ghost ist aktiv. <a href="%s">REST-API-Endpunkte</a> stehen unter <code>/wp-json/custom/v1/</code> zur Verfügung.', 'rbr-export-ghost'),
admin_url('tools.php')
);
echo '</p></div>';
}
});
/**
* CORS-Header erlauben (falls nötig für den C#-Importer)
*/
add_filter('rest_pre_serve_request', function ($served, $result, $request) {
if (strpos($request->get_route(), 'custom/v1/') === 0) {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
}
return $served;
}, 10, 3);
add_action('rest_api_init', function () {
// OPTIONS-Requests für CORS erlauben
register_rest_route('custom/v1', '/cors', array(
'methods' => 'OPTIONS',
'callback' => function () {
return new WP_REST_Response(null, 200);
},
'permission_callback' => '__return_true',
));
});