<?php
declare(strict_types=1);
namespace MeinWeimar;
use mysqli;
use WP_Error;
/**
* Controlled, repeatable importer for the local legacy WordPress snapshot.
*
* The source database is read through mysqli only. The target is written
* exclusively through WordPress APIs, so legacy GeoDirectory/EventON/YITH
* tables never become target dependencies.
*/
final class LegacyImporter
{
private static string $sourcePrefix = '';
private const MAPPING_VERSION = '2';
private const SOURCE_META = '_mw_legacy_source_key';
private const SOURCE_SYSTEM_META = '_mw_legacy_source_system';
private const SOURCE_ID_META = '_mw_legacy_source_id';
private const SOURCE_HASH_META = '_mw_legacy_source_hash';
private const SOURCE_STATUS_META = '_mw_legacy_import_status';
public static function run(array $options = []): array
{
self::loadMediaDependencies();
$options = wp_parse_args($options, [
'source_config' => dirname(ABSPATH) . '/wp-config.php',
'source_root' => dirname(ABSPATH),
'source_uploads' => dirname(ABSPATH) . '/wp-content/uploads',
'publish' => false,
]);
$connection = self::connectSource((string) $options['source_config']);
$source = $connection['db'];
self::$sourcePrefix = (string) $connection['prefix'];
ContentTypes::seed_place_categories();
$stats = [
'mode' => !empty($options['publish']) ? 'publish-test-data' : 'draft-import',
'mapping_version' => self::MAPPING_VERSION,
'posts_created' => 0,
'posts_updated' => 0,
'posts_skipped' => 0,
'posts_failed' => 0,
'terms_created' => 0,
'term_links' => 0,
'media_created' => 0,
'media_reused' => 0,
'media_missing' => 0,
'quarantined_events' => 0,
'sources' => [],
'errors' => [],
];
$resources = [
[
'site' => 1,
'source_type' => 'post',
'target_type' => 'mw_news',
'source_system' => 'wordpress-posts',
'taxonomies' => ['category'],
],
[
'site' => 1,
'source_type' => 'gd_place',
'target_type' => 'mw_place',
'source_system' => 'wordpress-geodirectory',
'detail_table' => 'geodir_gd_place_detail',
'taxonomies' => ['gd_placecategory'],
'category_root' => 'gd_place',
],
[
'site' => 1,
'source_type' => 'gd_kunstkultur',
'target_type' => 'mw_place',
'source_system' => 'wordpress-geodirectory',
'detail_table' => 'geodir_gd_kunstkultur_detail',
'taxonomies' => ['gd_kunstkulturcategory'],
'category_root' => 'gd_kunstkultur',
],
[
'site' => 1,
'source_type' => 'gd_freizeiterlebe',
'target_type' => 'mw_place',
'source_system' => 'wordpress-geodirectory',
'detail_table' => 'geodir_gd_freizeiterlebe_detail',
'taxonomies' => ['gd_freizeiterlebecategory'],
'category_root' => 'gd_freizeiterlebe',
],
[
'site' => 2,
'source_type' => 'ajde_events',
'target_type' => 'mw_event',
'source_system' => 'wordpress-eventon',
'taxonomies' => ['event_type', 'event_category'],
'event' => true,
],
[
'site' => 1,
'source_type' => 'tribe_events',
'target_type' => 'mw_event',
'source_system' => 'wordpress-tribe-events',
'taxonomies' => ['tribe_events_cat', 'event_category'],
'event' => true,
],
];
foreach ($resources as $resource) {
$sourceKey = $resource['source_system'] . ':blog:' . $resource['site'] . ':' . $resource['source_type'];
$stats['sources'][$sourceKey] = 0;
$rows = self::sourcePosts($source, (int) $resource['site'], (string) $resource['source_type']);
foreach ($rows as $row) {
$result = self::importPost($source, $row, $resource, $options, $stats);
if ($result) {
$stats['sources'][$sourceKey]++;
}
}
}
$source->close();
return $stats;
}
/**
* Import one local, non-post media asset and keep its source provenance.
* Used for shared platform assets such as the legacy map-pin fallback.
*/
public static function importLocalImage(string $sourcePath, string $sourceKey, string $title): int
{
self::loadMediaDependencies();
if (!is_file($sourcePath)) {
return 0;
}
$existing = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => self::SOURCE_META,
'meta_value' => $sourceKey,
]);
if ($existing) {
return (int) $existing[0];
}
$upload = wp_upload_dir();
if (!empty($upload['error']) || !wp_mkdir_p($upload['path'])) {
return 0;
}
$filename = wp_unique_filename($upload['path'], basename($sourcePath));
$targetPath = trailingslashit($upload['path']) . $filename;
if (!copy($sourcePath, $targetPath)) {
return 0;
}
$filetype = wp_check_filetype($filename, null);
$targetId = wp_insert_attachment([
'post_mime_type' => $filetype['type'] ?: 'application/octet-stream',
'post_title' => sanitize_text_field($title ?: pathinfo($filename, PATHINFO_FILENAME)),
'post_status' => 'inherit',
], $targetPath, 0, true);
if (is_wp_error($targetId)) {
@unlink($targetPath);
return 0;
}
$targetId = (int) $targetId;
$metadata = wp_generate_attachment_metadata($targetId, $targetPath);
if (is_array($metadata)) {
wp_update_attachment_metadata($targetId, $metadata);
}
update_post_meta($targetId, self::SOURCE_META, $sourceKey);
update_post_meta($targetId, self::SOURCE_SYSTEM_META, 'wordpress-media');
update_post_meta($targetId, self::SOURCE_HASH_META, hash_file('sha256', $sourcePath) ?: '');
return $targetId;
}
private static function importPost(mysqli $source, array $row, array $resource, array $options, array &$stats): bool
{
$sourceId = absint($row['ID'] ?? 0);
if (!$sourceId) {
$stats['posts_skipped']++;
return false;
}
$site = (int) $resource['site'];
$sourceType = (string) $resource['source_type'];
$targetType = (string) $resource['target_type'];
$sourceKey = sprintf('blog:%d:%s:%d', $site, $sourceType, $sourceId);
$detail = !empty($resource['detail_table'])
? self::sourceDetail($source, (string) $resource['detail_table'], $sourceId)
: [];
$sourceMeta = self::sourceMeta($source, $site, $sourceId);
$title = self::firstValue($detail, ['post_title'], (string) ($row['post_title'] ?? ''));
$title = trim(wp_strip_all_tags($title));
if ('' === $title) {
$stats['posts_skipped']++;
return false;
}
$isEvent = !empty($resource['event']);
$demoEvent = $isEvent && (bool) preg_match('/test[_ -]?event|demo|lorem/i', $title);
$placeholderEvent = $isEvent;
$targetStatus = !empty($options['publish']) && !$placeholderEvent ? 'publish' : 'draft';
$content = (string) ($row['post_content'] ?? '');
$excerpt = (string) ($row['post_excerpt'] ?? '');
$hash = hash('sha256', wp_json_encode([$sourceKey, $title, $content, $detail, $sourceMeta]));
$targetId = self::findTargetPost($sourceKey, $targetType);
$postarr = [
'post_type' => $targetType,
'post_status' => $targetStatus,
'post_title' => $title,
'post_name' => sanitize_title((string) ($row['post_name'] ?? $title)),
'post_content' => wp_kses_post($content),
'post_excerpt' => wp_kses_post($excerpt),
'post_date' => self::validDate((string) ($row['post_date'] ?? '')),
'post_date_gmt' => self::validDate((string) ($row['post_date_gmt'] ?? '')),
];
if ($targetId) {
$postarr['ID'] = $targetId;
$savedId = wp_update_post(wp_slash($postarr), true);
$stats['posts_updated']++;
} else {
$savedId = wp_insert_post(wp_slash($postarr), true);
$stats['posts_created']++;
}
if (is_wp_error($savedId)) {
$stats['posts_failed']++;
$stats['errors'][] = [
'source_key' => $sourceKey,
'message' => $savedId->get_error_message(),
];
return false;
}
$targetId = (int) $savedId;
update_post_meta($targetId, self::SOURCE_META, $sourceKey);
update_post_meta($targetId, self::SOURCE_SYSTEM_META, (string) $resource['source_system']);
update_post_meta($targetId, self::SOURCE_ID_META, $sourceId);
update_post_meta($targetId, self::SOURCE_HASH_META, $hash);
update_post_meta(
$targetId,
self::SOURCE_STATUS_META,
$demoEvent ? 'quarantine-demo' : ($placeholderEvent ? 'quarantine-placeholder' : 'imported')
);
$targetRoute = ContentTypes::route_for_post_type($targetType);
$locale = FieldSchemas::sanitize_value('mw_content_locale', 'de_DE', $targetRoute);
if ('' !== (string) $locale) {
update_post_meta($targetId, 'mw_content_locale', $locale);
}
if (in_array($targetRoute, ['places', 'services'], true)) {
self::writePlaceMeta($targetId, $detail, $targetRoute);
}
if ($isEvent) {
self::writeEventMeta($targetId, $sourceMeta, $detail, $demoEvent, $stats);
}
$mediaIds = self::sourceMediaIds($source, $site, $sourceId, $sourceMeta, $detail);
$targetMediaIds = [];
foreach ($mediaIds as $mediaId) {
$targetMediaId = self::importAttachment($source, $site, $mediaId, $targetId, $options, $stats);
if ($targetMediaId) {
$targetMediaIds[] = $targetMediaId;
}
}
if ($targetMediaIds) {
$gallery = ContentTypes::sanitize_gallery_ids($targetMediaIds);
if ($gallery) {
set_post_thumbnail($targetId, $gallery[0]);
update_post_meta($targetId, 'mw_gallery_ids', $gallery);
}
}
$categoryTaxonomy = ContentTypes::category_taxonomy($targetType);
if ($categoryTaxonomy) {
$termIds = self::importTerms(
$source,
$site,
$sourceId,
(array) ($resource['taxonomies'] ?? []),
$categoryTaxonomy,
$stats,
(string) ($resource['category_root'] ?? '')
);
if ($termIds) {
wp_set_post_terms($targetId, $termIds, $categoryTaxonomy, false);
$stats['term_links'] += count($termIds);
}
}
$tagIds = self::importTerms($source, $site, $sourceId, ['post_tag'], 'mw_tag', $stats);
$legacyTags = self::firstValue($detail, ['post_tags']);
if ('' !== $legacyTags) {
foreach (preg_split('/[,|]+/', $legacyTags) ?: [] as $tagName) {
$tagName = trim(wp_strip_all_tags((string) $tagName));
if ('' === $tagName) {
continue;
}
$existing = term_exists($tagName, 'mw_tag');
if ($existing) {
$tagIds[] = (int) (is_array($existing) ? ($existing['term_id'] ?? 0) : $existing);
continue;
}
$created = wp_insert_term($tagName, 'mw_tag', ['slug' => sanitize_title($tagName)]);
if (!is_wp_error($created)) {
$tagIds[] = (int) ($created['term_id'] ?? 0);
$stats['terms_created']++;
}
}
}
$tagIds = array_values(array_unique(array_filter(array_map('absint', $tagIds))));
if ($tagIds) {
wp_set_post_terms($targetId, $tagIds, 'mw_tag', false);
$stats['term_links'] += count($tagIds);
}
self::importSeoMeta($targetId, $sourceMeta);
if ($isEvent) {
$stats['quarantined_events']++;
}
return true;
}
private static function writePlaceMeta(int $targetId, array $detail, string $route): void
{
$website = self::firstValue($detail, ['website']);
$phone = self::firstValue($detail, ['phone', 'telefon']);
$hours = self::firstValue($detail, ['business_hours']);
$expireDate = self::firstValue($detail, ['expire_date']);
$featured = self::firstValue($detail, ['featured']);
$rights = self::firstValue($detail, ['bildrechte']);
$claimed = self::firstValue($detail, ['claimed']);
$mapZoom = self::firstValue($detail, ['mapzoom']);
$fields = [
'mw_address_line1' => self::firstValue($detail, ['street']),
'mw_address_line2' => self::firstValue($detail, ['street2']),
'mw_postal_code' => self::firstValue($detail, ['zip']),
'mw_locality' => self::firstValue($detail, ['city']),
'mw_region' => self::firstValue($detail, ['region']),
'mw_country' => self::firstValue($detail, ['country'], 'DE'),
'mw_phone' => $phone,
'mw_email' => sanitize_email(self::firstValue($detail, ['email'])),
'mw_website_url' => esc_url_raw($website),
'mw_facebook_url' => esc_url_raw(self::firstValue($detail, ['facebook'])),
'mw_twitter_url' => esc_url_raw(self::firstValue($detail, ['twitter'])),
'mw_video_url' => esc_url_raw(self::firstValue($detail, ['video'])),
'mw_opening_hours' => $hours,
'mw_rating_average' => is_numeric(self::firstValue($detail, ['overall_rating'])) ? max(0, min(5, (float) self::firstValue($detail, ['overall_rating']))) : '',
'mw_rating_count' => is_numeric(self::firstValue($detail, ['rating_count'])) ? max(0, (int) self::firstValue($detail, ['rating_count'])) : '',
'mw_legacy_package_id' => is_numeric(self::firstValue($detail, ['package_id'])) ? absint(self::firstValue($detail, ['package_id'])) : '',
'mw_special_offers' => sanitize_textarea_field(self::firstValue($detail, ['special_offers'])),
'mw_neighbourhood' => self::firstValue($detail, ['neighbourhood']),
'mw_featured' => in_array(strtolower($featured), ['1', 'true', 'yes'], true) ? 1 : 0,
'mw_media_rights_status' => in_array(strtolower($rights), ['1', 'true', 'yes'], true) ? 'legacy-confirmed' : 'unknown',
'mw_claim_status' => in_array(strtolower($claimed), ['1', 'true', 'yes'], true) ? 'claimed' : 'unclaimed',
'mw_map_style' => self::firstValue($detail, ['mapview']),
'mw_map_zoom' => is_numeric($mapZoom) ? max(1, min(20, (int) $mapZoom)) : '',
'mw_expire_date' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $expireDate) && '0000-00-00' !== $expireDate ? $expireDate : '',
'mw_geo_latitude' => self::coordinate(self::firstValue($detail, ['latitude'])),
'mw_geo_longitude' => self::coordinate(self::firstValue($detail, ['longitude'])),
'mw_geo_precision' => 'exact',
'mw_geo_source' => 'legacy',
'mw_geo_privacy' => 'public',
];
self::writeSanitizedMeta($targetId, $route, $fields);
}
/**
* Persist only fields belonging to the import target route. Legacy source
* names are mapped by the caller, but validation and storage remain owned
* by the canonical FieldSchemas registry.
*
* @param array<string, mixed> $fields
*/
private static function writeSanitizedMeta(int $targetId, string $route, array $fields): void
{
foreach ($fields as $key => $value) {
if (!FieldSchemas::has($route, $key)) {
continue;
}
$value = FieldSchemas::sanitize_value($key, $value, $route);
if ('' === (string) $value) {
delete_post_meta($targetId, $key);
continue;
}
update_post_meta($targetId, $key, $value);
}
}
private static function writeEventMeta(int $targetId, array $sourceMeta, array $detail, bool $demoEvent, array &$stats): void
{
$start = self::eventTimestamp($sourceMeta, ['_unix_start_ev', '_EventStartDateUTC', '_EventStartDate']);
$end = self::eventTimestamp($sourceMeta, ['_unix_end_ev', '_EventEndDateUTC', '_EventEndDate']);
if ($start) {
update_post_meta($targetId, 'mw_event_start', FieldSchemas::sanitize_value('mw_event_start', $start, 'events'));
}
if ($end) {
update_post_meta($targetId, 'mw_event_end', FieldSchemas::sanitize_value('mw_event_end', $end, 'events'));
}
$sourceStatus = strtolower((string) ($sourceMeta['_status'] ?? 'scheduled'));
$status = in_array($sourceStatus, ['cancelled', 'postponed', 'expired'], true) ? $sourceStatus : 'scheduled';
update_post_meta($targetId, 'mw_event_status', FieldSchemas::sanitize_value('mw_event_status', $status, 'events'));
if ($demoEvent) {
update_post_meta($targetId, self::SOURCE_STATUS_META, 'quarantine-demo');
}
$lat = self::coordinate(self::firstValue($detail, ['latitude']));
$lng = self::coordinate(self::firstValue($detail, ['longitude']));
if ('' !== $lat && '' !== $lng) {
foreach ([
'mw_geo_latitude' => $lat,
'mw_geo_longitude' => $lng,
'mw_geo_precision' => 'exact',
'mw_geo_source' => 'legacy',
'mw_geo_privacy' => 'public',
] as $key => $value) {
if (FieldSchemas::has('events', $key)) {
update_post_meta($targetId, $key, FieldSchemas::sanitize_value($key, $value, 'events'));
}
}
}
if (!$start) {
$stats['errors'][] = ['target_id' => $targetId, 'message' => 'Event ohne verwertbaren Startzeitpunkt importiert'];
}
}
private static function importTerms(mysqli $source, int $site, int $sourcePostId, array $taxonomies, string $targetTaxonomy, array &$stats, string $categoryRoot = ''): array
{
if (!$taxonomies) {
return [];
}
$termsTable = self::table($source, 'terms', $site);
$termTaxonomyTable = self::table($source, 'term_taxonomy', $site);
$relationshipsTable = self::table($source, 'term_relationships', $site);
$taxonomySql = implode(',', array_map(static fn (string $taxonomy): string => "'" . esc_sql($taxonomy) . "'", $taxonomies));
$sql = "SELECT DISTINCT t.term_id,t.name,t.slug,tt.taxonomy
FROM `{$relationshipsTable}` tr
INNER JOIN `{$termTaxonomyTable}` tt ON tt.term_taxonomy_id=tr.term_taxonomy_id
INNER JOIN `{$termsTable}` t ON t.term_id=tt.term_id
WHERE tr.object_id=" . absint($sourcePostId) . " AND tt.taxonomy IN ({$taxonomySql})
ORDER BY t.name";
$rows = self::queryRows($source, $sql);
$targetIds = [];
$rootId = 0;
if ('mw_place_category' === $targetTaxonomy && '' !== $categoryRoot) {
$roots = ContentTypes::place_category_roots();
$rootSlug = '';
foreach ($roots as $slug => $definition) {
if ($definition['source_post_type'] === $categoryRoot) {
$rootSlug = $slug;
break;
}
}
if ('' !== $rootSlug) {
$root = term_exists($rootSlug, $targetTaxonomy);
$rootId = is_array($root)
? (int) ($root['term_id'] ?? 0)
: (is_int($root) ? $root : 0);
}
}
foreach ($rows as $row) {
$name = trim(wp_strip_all_tags((string) ($row['name'] ?? '')));
if ('' === $name) {
continue;
}
$slug = sanitize_title((string) ($row['slug'] ?? $name));
$sourceTermKey = sprintf('blog:%d:%s:%d', $site, $row['taxonomy'], (int) $row['term_id']);
$existingBySource = get_terms([
'taxonomy' => $targetTaxonomy,
'hide_empty' => false,
'fields' => 'ids',
'number' => 1,
'meta_query' => [[
'key' => self::SOURCE_META,
'value' => $sourceTermKey,
'compare' => '=',
]],
]);
$existing = $existingBySource && !is_wp_error($existingBySource)
? (int) $existingBySource[0]
: term_exists($slug, $targetTaxonomy);
if ($existing) {
$candidateId = (int) (is_array($existing) ? ($existing['term_id'] ?? 0) : $existing);
$candidateSource = (string) get_term_meta($candidateId, self::SOURCE_META, true);
if ('' !== $candidateSource && $candidateSource !== $sourceTermKey) {
$existing = 0;
}
}
if ($existing) {
$termId = (int) (is_array($existing) ? ($existing['term_id'] ?? 0) : $existing);
$term_update = ['name' => $name];
if ('mw_place_category' === $targetTaxonomy) {
$term_update['parent'] = $rootId;
}
$updated = wp_update_term($termId, $targetTaxonomy, $term_update);
if (is_wp_error($updated)) {
$stats['errors'][] = ['message' => $updated->get_error_message(), 'term' => $name];
continue;
}
} else {
$term_insert = ['slug' => $slug];
if ('mw_place_category' === $targetTaxonomy) {
$term_insert['parent'] = $rootId;
}
$created = wp_insert_term($name, $targetTaxonomy, $term_insert);
if (is_wp_error($created) && 'mw_place_category' === $targetTaxonomy && $rootId) {
$created = wp_insert_term($name, $targetTaxonomy, [
'slug' => sanitize_title($categoryRoot . '-' . $slug),
'parent' => $rootId,
]);
}
if (is_wp_error($created)) {
$stats['errors'][] = ['message' => $created->get_error_message(), 'term' => $name];
continue;
}
$termId = (int) ($created['term_id'] ?? 0);
$stats['terms_created']++;
}
if (!$termId) {
continue;
}
update_term_meta($termId, self::SOURCE_META, $sourceTermKey);
$targetIds[] = $termId;
}
return array_values(array_unique(array_filter(array_map('absint', $targetIds))));
}
private static function importSeoMeta(int $targetId, array $sourceMeta): void
{
foreach ($sourceMeta as $key => $value) {
if (!str_starts_with((string) $key, 'rank_math_')) {
continue;
}
update_post_meta($targetId, sanitize_key((string) $key), maybe_unserialize($value));
}
}
private static function importAttachment(mysqli $source, int $site, int $sourceId, int $targetParent, array $options, array &$stats): int
{
$sourceKey = sprintf('blog:%d:attachment:%d', $site, $sourceId);
$existing = get_posts([
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => self::SOURCE_META,
'meta_value' => $sourceKey,
]);
if ($existing) {
$stats['media_reused']++;
return (int) $existing[0];
}
$attachment = self::sourcePost($source, $site, $sourceId);
if (!$attachment || 'attachment' !== ($attachment['post_type'] ?? '')) {
$stats['media_missing']++;
return 0;
}
$meta = self::sourceMeta($source, $site, $sourceId);
$relative = ltrim((string) ($meta['_wp_attached_file'] ?? ''), '/');
if ('' === $relative) {
$stats['media_missing']++;
return 0;
}
$sourceRoot = rtrim((string) $options['source_uploads'], '/');
$candidates = [$sourceRoot . '/' . $relative];
if ($site > 1) {
array_unshift($candidates, $sourceRoot . '/sites/' . $site . '/' . $relative);
}
$sourcePath = '';
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$sourcePath = $candidate;
break;
}
}
if ('' === $sourcePath) {
$stats['media_missing']++;
return 0;
}
$upload = wp_upload_dir(self::validDate((string) ($attachment['post_date'] ?? '')));
if (!empty($upload['error']) || !wp_mkdir_p($upload['path'])) {
$stats['media_missing']++;
return 0;
}
$filename = wp_unique_filename($upload['path'], basename($relative));
$targetPath = trailingslashit($upload['path']) . $filename;
if (!copy($sourcePath, $targetPath)) {
$stats['media_missing']++;
return 0;
}
$filetype = wp_check_filetype($filename, null);
$targetId = wp_insert_attachment([
'post_mime_type' => $filetype['type'] ?: 'application/octet-stream',
'post_title' => sanitize_text_field((string) ($attachment['post_title'] ?? pathinfo($filename, PATHINFO_FILENAME))),
'post_excerpt' => sanitize_text_field((string) ($attachment['post_excerpt'] ?? '')),
'post_status' => 'inherit',
'post_parent' => $targetParent,
'post_date' => self::validDate((string) ($attachment['post_date'] ?? '')),
], $targetPath, $targetParent, true);
if (is_wp_error($targetId)) {
@unlink($targetPath);
$stats['media_missing']++;
$stats['errors'][] = ['source_key' => $sourceKey, 'message' => $targetId->get_error_message()];
return 0;
}
$targetId = (int) $targetId;
$metadata = wp_generate_attachment_metadata($targetId, $targetPath);
if (is_array($metadata)) {
wp_update_attachment_metadata($targetId, $metadata);
}
$alt = (string) ($meta['_wp_attachment_image_alt'] ?? '');
if ('' !== $alt) {
update_post_meta($targetId, '_wp_attachment_image_alt', sanitize_text_field($alt));
}
update_post_meta($targetId, self::SOURCE_META, $sourceKey);
update_post_meta($targetId, self::SOURCE_SYSTEM_META, 'wordpress-media');
update_post_meta($targetId, self::SOURCE_ID_META, $sourceId);
update_post_meta($targetId, self::SOURCE_HASH_META, hash_file('sha256', $sourcePath) ?: '');
$stats['media_created']++;
return $targetId;
}
private static function sourceMediaIds(mysqli $source, int $site, int $sourcePostId, array $sourceMeta, array $detail): array
{
$ids = [];
foreach (['_thumbnail_id', '_featured_image_id'] as $key) {
if (!empty($sourceMeta[$key])) {
$ids[] = absint($sourceMeta[$key]);
}
}
$featured = self::firstValue($detail, ['featured_image']);
if (ctype_digit($featured)) {
$ids[] = absint($featured);
} elseif ('' !== $featured) {
$postsTable = self::table($source, 'posts', $site);
$metaTable = self::table($source, 'postmeta', $site);
$filename = basename((string) (parse_url($featured, PHP_URL_PATH) ?: $featured));
$filename = str_replace(['%', '_'], ['\\%', '\\_'], $filename);
$filename = $source->real_escape_string($filename);
if ('' !== $filename) {
$rows = self::queryRows(
$source,
"SELECT DISTINCT p.ID FROM `{$postsTable}` p
LEFT JOIN `{$metaTable}` pm ON pm.post_id=p.ID AND pm.meta_key='_wp_attached_file'
WHERE p.post_type='attachment'
AND (p.guid LIKE '%{$filename}' OR pm.meta_value LIKE '%{$filename}')
ORDER BY p.ID"
);
foreach ($rows as $row) {
$ids[] = absint($row['ID'] ?? 0);
}
}
}
$postsTable = self::table($source, 'posts', $site);
$children = self::queryRows($source, "SELECT ID FROM `{$postsTable}` WHERE post_type='attachment' AND post_parent=" . absint($sourcePostId) . " ORDER BY ID");
foreach ($children as $child) {
$ids[] = absint($child['ID'] ?? 0);
}
return array_values(array_unique(array_filter($ids)));
}
private static function sourcePosts(mysqli $source, int $site, string $postType): array
{
$table = self::table($source, 'posts', $site);
$type = $source->real_escape_string($postType);
return self::queryRows($source, "SELECT * FROM `{$table}` WHERE post_type='{$type}' AND post_status='publish' ORDER BY ID");
}
private static function sourcePost(mysqli $source, int $site, int $postId): array
{
$table = self::table($source, 'posts', $site);
$rows = self::queryRows($source, "SELECT * FROM `{$table}` WHERE ID=" . absint($postId) . " LIMIT 1");
return $rows[0] ?? [];
}
private static function sourceDetail(mysqli $source, string $tableSuffix, int $postId): array
{
$table = self::table($source, $tableSuffix, 1);
$rows = self::queryRows($source, "SELECT * FROM `{$table}` WHERE post_id=" . absint($postId) . " LIMIT 1");
return $rows[0] ?? [];
}
private static function sourceMeta(mysqli $source, int $site, int $postId): array
{
$table = self::table($source, 'postmeta', $site);
$rows = self::queryRows($source, "SELECT meta_key,meta_value FROM `{$table}` WHERE post_id=" . absint($postId));
$meta = [];
foreach ($rows as $row) {
$key = (string) ($row['meta_key'] ?? '');
if ('' !== $key && !array_key_exists($key, $meta)) {
$meta[$key] = (string) ($row['meta_value'] ?? '');
}
}
return $meta;
}
private static function connectSource(string $configPath): array
{
if (!is_file($configPath)) {
throw new \RuntimeException('Source wp-config.php not found: ' . $configPath);
}
$config = (string) file_get_contents($configPath);
$value = static function (string $name) use ($config): string {
$pattern = '/define\s*\(\s*["\x27]' . preg_quote($name, '/') . '["\x27]\s*,\s*(["\x27])(.*?)\1\s*\)/s';
if (preg_match($pattern, $config, $matches) !== 1) {
throw new \RuntimeException('Missing source config value: ' . $name);
}
return stripcslashes($matches[2]);
};
if (preg_match('/\$table_prefix\s*=\s*(["\x27])(.*?)\1\s*;/', $config, $prefixMatches) !== 1) {
throw new \RuntimeException('Missing source table prefix');
}
$hostValue = $value('DB_HOST');
$host = $hostValue;
$socket = null;
$port = 0;
if (str_contains($hostValue, ':')) {
[$host, $tail] = explode(':', $hostValue, 2);
if (str_starts_with($tail, '/')) {
$socket = $tail;
} else {
$port = absint($tail);
}
}
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli($host, $value('DB_USER'), $value('DB_PASSWORD'), $value('DB_NAME'), $port, $socket);
if ($db->connect_errno) {
throw new \RuntimeException('Source database connection failed: ' . $db->connect_error);
}
$db->set_charset('utf8mb4');
return ['db' => $db, 'prefix' => $prefixMatches[2], 'database' => $value('DB_NAME')];
}
private static function table(mysqli $source, string $name, int $site): string
{
return self::$sourcePrefix . ($site > 1 ? $site . '_' : '') . $name;
}
private static function queryRows(mysqli $source, string $sql): array
{
$result = $source->query($sql);
if (!$result) {
throw new \RuntimeException('Legacy read failed: ' . $source->error);
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}
$result->free();
return $rows;
}
private static function findTargetPost(string $sourceKey, string $postType): int
{
$ids = get_posts([
'post_type' => $postType,
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => self::SOURCE_META,
'meta_value' => $sourceKey,
]);
return $ids ? (int) $ids[0] : 0;
}
private static function firstValue(array $row, array $keys, string $default = ''): string
{
foreach ($keys as $key) {
$value = trim((string) ($row[$key] ?? ''));
if ('' !== $value) {
return $value;
}
}
return $default;
}
private static function coordinate(string $value): string
{
if (!is_numeric($value)) {
return '';
}
$number = (float) $value;
return is_finite($number) ? (string) $number : '';
}
private static function eventTimestamp(array $meta, array $keys): string
{
foreach ($keys as $key) {
$value = trim((string) ($meta[$key] ?? ''));
if ('' === $value) {
continue;
}
$timestamp = ctype_digit($value) ? (int) $value : strtotime($value);
if ($timestamp) {
return wp_date(DATE_ATOM, $timestamp, wp_timezone());
}
}
return '';
}
private static function validDate(string $value): string
{
return preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value) ? $value : current_time('mysql');
}
private static function loadMediaDependencies(): void
{
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
}
}
